Wide (>=1024px): right-hand pane beside the table with a close control. Narrow: Base UI Drawer sliding from the right (lazy-loaded so its code splits out of the main chunk). Both preserve the table's query string on close. Remove the index SelectPrompt route (the table is the landing view) and delete the now-unused SelectPrompt. Make table rows keyboard-activatable (role=link, tabIndex, Enter). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer";
|
||||
|
||||
/**
|
||||
* Narrow-viewport object detail: the nested <Outlet/> inside a Base UI Drawer that
|
||||
* slides from the right. Lazy-loaded so Base UI's drawer code (swipe/snap machinery)
|
||||
* splits out of the main entry chunk — the wide pane path never pays for it.
|
||||
*/
|
||||
export function ObjectDetailDrawer({
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) onClose();
|
||||
}}
|
||||
swipeDirection="right"
|
||||
>
|
||||
<DrawerContent>
|
||||
<div className="flex justify-end border-b p-2">
|
||||
<DrawerClose
|
||||
aria-label={t("actions.closeDetail")}
|
||||
className="rounded p-1 text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900"
|
||||
>
|
||||
<X className="size-4" aria-hidden="true" />
|
||||
</DrawerClose>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Outlet />
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +1,82 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import { renderApp } from "../test/render";
|
||||
import { ObjectsPage } from "./objects-page";
|
||||
import { ObjectDetail } from "./object-detail";
|
||||
import { SelectPrompt } from "./select-prompt";
|
||||
|
||||
function tree() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/objects" element={<ObjectsPage />}>
|
||||
<Route index element={<SelectPrompt />} />
|
||||
<Route path=":id" element={<ObjectDetail />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
test("the table is the landing view; the detail prompt is not a fixed column", async () => {
|
||||
renderApp(tree(), { route: "/objects" });
|
||||
// The shared setup stub returns `matches: false` (narrow). Override per-test to
|
||||
// flip the `(min-width: 1024px)` query so we can exercise both layouts.
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
// Table rows render full-width; no detail panel (and thus no prompt) until a row is opened.
|
||||
expect(await screen.findByText("Amphora")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/select an object/i)).not.toBeInTheDocument();
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("clicking a row opens its detail in the side panel", async () => {
|
||||
test("the table is the landing view; no detail panel until a row is opened", async () => {
|
||||
setViewport(true);
|
||||
renderApp(tree(), { route: "/objects" });
|
||||
|
||||
expect(await screen.findByText("Amphora")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /close detail/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("wide: clicking a row opens detail in the right pane with a close control", async () => {
|
||||
setViewport(true);
|
||||
renderApp(tree(), { route: "/objects" });
|
||||
await userEvent.click(await screen.findByText("Amphora"));
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Amphora" })).toBeInTheDocument();
|
||||
|
||||
const close = screen.getByRole("button", { name: /close detail/i });
|
||||
await userEvent.click(close);
|
||||
|
||||
// Back to the table-only view: the detail heading is gone, the table remains.
|
||||
expect(screen.queryByRole("heading", { name: "Amphora" })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Amphora")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("narrow: detail renders inside a portaled drawer", async () => {
|
||||
setViewport(false);
|
||||
renderApp(tree(), { route: "/objects" });
|
||||
await userEvent.click(await screen.findByText("Amphora"));
|
||||
|
||||
// The drawer popup is portaled to document.body.
|
||||
const body = within(document.body);
|
||||
expect(await body.findByRole("heading", { name: "Amphora" })).toBeInTheDocument();
|
||||
expect(body.getByRole("button", { name: /close detail/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("wide: deep-linking /objects/:id renders the table and the open detail", async () => {
|
||||
setViewport(true);
|
||||
renderApp(tree(), { route: "/objects/11111111-1111-1111-1111-111111111111" });
|
||||
|
||||
expect(await screen.findByText("Amphora")).toBeInTheDocument();
|
||||
expect(await screen.findByRole("heading", { name: "Amphora" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,24 +1,70 @@
|
||||
import { Outlet, useMatch } from "react-router-dom";
|
||||
import { lazy, Suspense } from "react";
|
||||
import { Outlet, useMatch, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { ObjectsTable } from "./objects-table";
|
||||
import { useMediaQuery } from "../lib/use-media-query";
|
||||
|
||||
const ObjectDetailDrawer = lazy(() =>
|
||||
import("./object-detail-drawer").then((m) => ({ default: m.ObjectDetailDrawer })),
|
||||
);
|
||||
|
||||
export function ObjectsPage() {
|
||||
// Interim layout (Phase 3 makes this a responsive pane/drawer): the table is the
|
||||
// full-width landing view; when a `:id`/`:id/edit` child route is active we render
|
||||
// the nested <Outlet/> as a simple right-side panel.
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// The table is the full-width landing view. When a `:id`/`:id/edit` child route
|
||||
// is active we surface the nested <Outlet/> as a right-hand pane (wide) or a
|
||||
// Drawer sliding from the right (narrow), preserving the table's query string on close.
|
||||
const detailMatch = useMatch("/objects/:id");
|
||||
const editMatch = useMatch("/objects/:id/edit");
|
||||
const detail = detailMatch ?? editMatch;
|
||||
const open = Boolean(detailMatch ?? editMatch);
|
||||
const isWide = useMediaQuery("(min-width: 1024px)");
|
||||
|
||||
return (
|
||||
<div className={`grid h-full ${detail ? "grid-cols-[1fr_28rem]" : "grid-cols-1"}`}>
|
||||
<div className="overflow-hidden">
|
||||
<ObjectsTable />
|
||||
const closeDetail = () => navigate(`/objects?${searchParams}`);
|
||||
|
||||
const table = (
|
||||
<div className="overflow-hidden">
|
||||
<ObjectsTable />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isWide) {
|
||||
return (
|
||||
<div className={`grid h-full ${open ? "grid-cols-[1fr_28rem]" : "grid-cols-1"}`}>
|
||||
{table}
|
||||
{open && (
|
||||
<div className="flex h-full flex-col overflow-hidden border-l">
|
||||
<div className="flex justify-end border-b p-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeDetail}
|
||||
aria-label={t("actions.closeDetail")}
|
||||
className="rounded p-1 text-neutral-500 hover:bg-neutral-100 hover:text-neutral-900"
|
||||
>
|
||||
<X className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{detail && (
|
||||
<div className="overflow-auto border-l">
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Narrow: the detail lives in a Drawer, lazy-loaded so Base UI's drawer code stays
|
||||
// out of the main entry chunk.
|
||||
return (
|
||||
<div className="h-full">
|
||||
{table}
|
||||
{open && (
|
||||
<Suspense fallback={null}>
|
||||
<ObjectDetailDrawer open={open} onClose={closeDetail} />
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -246,8 +246,13 @@ export function ObjectsTable() {
|
||||
return (
|
||||
<tr
|
||||
key={object.id}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
aria-selected={selected}
|
||||
onClick={() => navigate(`/objects/${object.id}?${params}`)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") navigate(`/objects/${object.id}?${params}`);
|
||||
}}
|
||||
className={`cursor-pointer border-b text-sm ${
|
||||
selected ? "bg-indigo-50" : "hover:bg-neutral-50"
|
||||
}`}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function SelectPrompt() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-4 text-sm text-neutral-400">
|
||||
{t("objects.selectPrompt")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user