diff --git a/web/src/vocab/vocabularies-page.test.tsx b/web/src/vocab/vocabularies-page.test.tsx
new file mode 100644
index 0000000..67894d2
--- /dev/null
+++ b/web/src/vocab/vocabularies-page.test.tsx
@@ -0,0 +1,57 @@
+import { afterEach, expect, test, vi } from "vitest";
+import { screen, within } from "@testing-library/react";
+import { Route, Routes } from "react-router-dom";
+
+import { renderApp } from "../test/render";
+import { VocabulariesPage } from "./vocabularies-page";
+import { VocabularyTerms } from "./vocabulary-terms";
+import { SelectVocabularyPrompt } from "./select-vocabulary-prompt";
+
+function setViewport(wide: boolean) {
+ Object.defineProperty(window, "matchMedia", {
+ value: (query: string): MediaQueryList =>
+ ({
+ matches: wide && query === "(min-width: 1024px)",
+ media: query,
+ onchange: null,
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ addListener: () => {},
+ removeListener: () => {},
+ dispatchEvent: () => false,
+ }) as MediaQueryList,
+ writable: true,
+ });
+}
+
+afterEach(() => vi.restoreAllMocks());
+
+function tree() {
+ return (
+
+ }>
+ } />
+ } />
+
+
+ );
+}
+
+test("narrow: a selected vocabulary's detail renders in a portaled drawer", async () => {
+ setViewport(false);
+ renderApp(tree(), { route: "/vocabularies/v-material" });
+
+ const body = within(document.body);
+ expect(
+ await body.findByRole("button", { name: /close detail/i }, { timeout: 5000 }),
+ ).toBeInTheDocument();
+});
+
+test("wide: a selected vocabulary renders inline, with no detail drawer", async () => {
+ setViewport(true);
+ renderApp(tree(), { route: "/vocabularies/v-material" });
+
+ // VocabularyTerms renders its "Terms" caption inline in the right pane.
+ await screen.findByText(/terms/i);
+ expect(screen.queryByRole("button", { name: /close detail/i })).toBeNull();
+});
diff --git a/web/src/vocab/vocabularies-page.tsx b/web/src/vocab/vocabularies-page.tsx
index a82c8d1..2dfd617 100644
--- a/web/src/vocab/vocabularies-page.tsx
+++ b/web/src/vocab/vocabularies-page.tsx
@@ -1,28 +1,47 @@
-import { Outlet } from "react-router-dom";
+import { Outlet, useMatch, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { VocabularyList } from "./vocabulary-list";
+import { DetailDrawer } from "../components/detail-drawer";
+import { useMediaQuery } from "../lib/use-media-query";
import { useDocumentTitle } from "../lib/use-document-title";
import { useBreadcrumb } from "../shell/use-breadcrumb";
import { PageTitle } from "@/components/ui/page-title";
export function VocabulariesPage() {
const { t } = useTranslation();
+ const navigate = useNavigate();
+ const detailMatch = useMatch("/vocabularies/:id");
+ const open = Boolean(detailMatch);
+ const isWide = useMediaQuery("(min-width: 1024px)");
useDocumentTitle(t("nav.vocabularies"));
useBreadcrumb([{ label: t("nav.vocabularies") }]);
+ const close = () => navigate("/vocabularies");
+
return (
{t("nav.vocabularies")}
-
-
+ {isWide ? (
+
+ ) : (
+
-
+ )}
+ {!isWide && open && (
+
-
-
+
+ )}
);
}