feat(web): responsive object detail (pane/drawer) at canonical /objects/:id (#44, #58)

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:
2026-06-06 23:53:10 +02:00
parent 184e4ea2a5
commit b8f70212a1
9 changed files with 234 additions and 38 deletions
+59 -13
View File
@@ -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>
);