diff --git a/app/package-lock.json b/app/package-lock.json
index 5b91fc53..93afbda8 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -47,6 +47,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",
@@ -15926,7 +15927,6 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
- "dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
@@ -20484,6 +20484,16 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
+ "node_modules/next-router-mock": {
+ "version": "0.9.10",
+ "resolved": "https://registry.npmjs.org/next-router-mock/-/next-router-mock-0.9.10.tgz",
+ "integrity": "sha512-bK6sRb/xGNFgHVUZuvuApn6KJBAKTPiP36A7a9mO77U4xQO5ukJx9WHlU67Tv8AuySd09pk0+Hu8qMVIAmLO6A==",
+ "dev": true,
+ "peerDependencies": {
+ "next": ">=10.0.0",
+ "react": ">=17.0.0"
+ }
+ },
"node_modules/no-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz",
@@ -37603,7 +37613,6 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
- "dev": true,
"optional": true
},
"function-bind": {
@@ -40894,6 +40903,13 @@
"use-intl": "^3.2.1"
}
},
+ "next-router-mock": {
+ "version": "0.9.10",
+ "resolved": "https://registry.npmjs.org/next-router-mock/-/next-router-mock-0.9.10.tgz",
+ "integrity": "sha512-bK6sRb/xGNFgHVUZuvuApn6KJBAKTPiP36A7a9mO77U4xQO5ukJx9WHlU67Tv8AuySd09pk0+Hu8qMVIAmLO6A==",
+ "dev": true,
+ "requires": {}
+ },
"no-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz",
diff --git a/app/package.json b/app/package.json
index 6275cde1..93dbb3c5 100644
--- a/app/package.json
+++ b/app/package.json
@@ -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",
diff --git a/app/src/components/Header.tsx b/app/src/components/Header.tsx
index 10a93db4..ec8ad65c 100644
--- a/app/src/components/Header.tsx
+++ b/app/src/components/Header.tsx
@@ -9,6 +9,8 @@ import {
Header as USWDSHeader,
} from "@trussworks/react-uswds";
+import LocaleSwitcher from "./LocaleSwitcher";
+
const primaryLinks = [
{
i18nKey: "nav_link_home",
@@ -28,11 +30,14 @@ const Header = () => {
setIsMobileNavExpanded(!isMobileNavExpanded);
};
- const navItems = primaryLinks.map((link) => (
-
- {t(link.i18nKey)}
-
- ));
+ const navItems = [
+ ...primaryLinks.map((link) => (
+
+ {t(link.i18nKey)}
+
+ )),
+ ,
+ ];
return (
<>
diff --git a/app/src/components/LocaleSwitcher.tsx b/app/src/components/LocaleSwitcher.tsx
new file mode 100644
index 00000000..d084671c
--- /dev/null
+++ b/app/src/components/LocaleSwitcher.tsx
@@ -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
+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 (
+
+ );
+}
diff --git a/app/src/i18n/navigation.ts b/app/src/i18n/navigation.ts
new file mode 100644
index 00000000..7dea3254
--- /dev/null
+++ b/app/src/i18n/navigation.ts
@@ -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 });
diff --git a/app/tests/components/LocaleSwitcher.test.tsx b/app/tests/components/LocaleSwitcher.test.tsx
new file mode 100644
index 00000000..81b7dccf
--- /dev/null
+++ b/app/tests/components/LocaleSwitcher.test.tsx
@@ -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();
+
+ // 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();
+ });
+
+ // 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();
+ const results = await axe(container);
+
+ expect(results).toHaveNoViolations();
+ });
+});
diff --git a/app/tests/jest.setup.ts b/app/tests/jest.setup.ts
index ce69f56e..605240e7 100644
--- a/app/tests/jest.setup.ts
+++ b/app/tests/jest.setup.ts
@@ -1,4 +1,5 @@
import "@testing-library/jest-dom";
+import "./next-router-utils";
import { toHaveNoViolations } from "jest-axe";
diff --git a/app/tests/next-router-utils.ts b/app/tests/next-router-utils.ts
new file mode 100644
index 00000000..4d421eda
--- /dev/null
+++ b/app/tests/next-router-utils.ts
@@ -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("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 };