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 };