From b83149e0bb4b074ca75e54b57777be17390b39c0 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Tue, 9 Jun 2026 15:15:44 +0200 Subject: [PATCH] feat(web): responsive Search master/detail (drawer on narrow) (#58) --- web/src/search/search-page.test.tsx | 56 +++++++++++++++++++++++++++++ web/src/search/search-page.tsx | 31 ++++++++++++---- 2 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 web/src/search/search-page.test.tsx diff --git a/web/src/search/search-page.test.tsx b/web/src/search/search-page.test.tsx new file mode 100644 index 0000000..d599d9b --- /dev/null +++ b/web/src/search/search-page.test.tsx @@ -0,0 +1,56 @@ +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 { SearchPage } from "./search-page"; +import { ObjectDetail } from "../objects/object-detail"; +import { SelectSearchPrompt } from "./select-search-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 result's detail renders in a portaled drawer", async () => { + setViewport(false); + renderApp(tree(), { route: "/search/11111111-1111-1111-1111-111111111111" }); + + const body = within(document.body); + expect( + await body.findByRole("button", { name: /close detail/i }, { timeout: 5000 }), + ).toBeInTheDocument(); +}); + +test("wide: a selected result renders inline, with no detail drawer", async () => { + setViewport(true); + renderApp(tree(), { route: "/search/11111111-1111-1111-1111-111111111111" }); + + expect(await screen.findByRole("heading", { name: "Amphora" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /close detail/i })).toBeNull(); +}); diff --git a/web/src/search/search-page.tsx b/web/src/search/search-page.tsx index 4084b13..7de97a2 100644 --- a/web/src/search/search-page.tsx +++ b/web/src/search/search-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 { SearchPanel } from "./search-panel"; +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 SearchPage() { const { t } = useTranslation(); + const navigate = useNavigate(); + const detailMatch = useMatch("/search/:id"); + const open = Boolean(detailMatch); + const isWide = useMediaQuery("(min-width: 1024px)"); useDocumentTitle(t("nav.search")); useBreadcrumb([{ label: t("nav.search") }]); + const close = () => navigate("/search"); + return (
{t("nav.search")} -
-
+ {isWide ? ( +
+
+ +
+
+ +
+
+ ) : ( +
-
+ )} + {!isWide && open && ( + -
-
+ + )}
); }