From 80c2aad298cfb67023512ac25e08bfb365995c2a Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Tue, 9 Jun 2026 15:12:45 +0200 Subject: [PATCH] feat(web): responsive Vocabularies master/detail (drawer on narrow) (#58) --- web/src/vocab/vocabularies-page.test.tsx | 57 ++++++++++++++++++++++++ web/src/vocab/vocabularies-page.tsx | 31 ++++++++++--- 2 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 web/src/vocab/vocabularies-page.test.tsx 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 && ( + -
-
+ + )}
); }