feat(web): responsive Search master/detail (drawer on narrow) (#58)

This commit is contained in:
2026-06-09 15:15:44 +02:00
parent 80c2aad298
commit b83149e0bb
2 changed files with 81 additions and 6 deletions
+56
View File
@@ -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 (
<Routes>
<Route path="/search" element={<SearchPage />}>
<Route index element={<SelectSearchPrompt />} />
<Route path=":id" element={<ObjectDetail />} />
</Route>
</Routes>
);
}
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();
});
+25 -6
View File
@@ -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 (
<div className="flex h-full flex-col">
<PageTitle className="px-4 pt-4 pb-2">{t("nav.search")}</PageTitle>
<div className="grid flex-1 grid-cols-[24rem_1fr] overflow-hidden">
<div className="overflow-hidden border-r">
{isWide ? (
<div className="grid flex-1 grid-cols-[24rem_1fr] overflow-hidden">
<div className="overflow-hidden border-r">
<SearchPanel />
</div>
<div className="overflow-hidden">
<Outlet />
</div>
</div>
) : (
<div className="flex-1 overflow-hidden">
<SearchPanel />
</div>
<div className="overflow-hidden">
)}
{!isWide && open && (
<DetailDrawer open={open} onClose={close} ariaLabel={t("objects.detailTitle")}>
<Outlet />
</div>
</div>
</DetailDrawer>
)}
</div>
);
}