feat(web): responsive Vocabularies master/detail (drawer on narrow) (#58)
This commit is contained in:
@@ -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 (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/vocabularies" element={<VocabulariesPage />}>
|
||||||
|
<Route index element={<SelectVocabularyPrompt />} />
|
||||||
|
<Route path=":id" element={<VocabularyTerms />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
@@ -1,20 +1,29 @@
|
|||||||
import { Outlet } from "react-router-dom";
|
import { Outlet, useMatch, useNavigate } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { VocabularyList } from "./vocabulary-list";
|
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 { useDocumentTitle } from "../lib/use-document-title";
|
||||||
import { useBreadcrumb } from "../shell/use-breadcrumb";
|
import { useBreadcrumb } from "../shell/use-breadcrumb";
|
||||||
import { PageTitle } from "@/components/ui/page-title";
|
import { PageTitle } from "@/components/ui/page-title";
|
||||||
|
|
||||||
export function VocabulariesPage() {
|
export function VocabulariesPage() {
|
||||||
const { t } = useTranslation();
|
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"));
|
useDocumentTitle(t("nav.vocabularies"));
|
||||||
useBreadcrumb([{ label: t("nav.vocabularies") }]);
|
useBreadcrumb([{ label: t("nav.vocabularies") }]);
|
||||||
|
|
||||||
|
const close = () => navigate("/vocabularies");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<PageTitle className="px-4 pt-4 pb-2">{t("nav.vocabularies")}</PageTitle>
|
<PageTitle className="px-4 pt-4 pb-2">{t("nav.vocabularies")}</PageTitle>
|
||||||
|
{isWide ? (
|
||||||
<div className="grid flex-1 grid-cols-[20rem_1fr] overflow-hidden">
|
<div className="grid flex-1 grid-cols-[20rem_1fr] overflow-hidden">
|
||||||
<div className="overflow-hidden border-r">
|
<div className="overflow-hidden border-r">
|
||||||
<VocabularyList />
|
<VocabularyList />
|
||||||
@@ -23,6 +32,16 @@ export function VocabulariesPage() {
|
|||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<VocabularyList />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isWide && open && (
|
||||||
|
<DetailDrawer open={open} onClose={close} ariaLabel={t("vocab.terms")}>
|
||||||
|
<Outlet />
|
||||||
|
</DetailDrawer>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user