From b8f70212a1ba38042f7eff40bdb3d9363b10855c Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Sat, 6 Jun 2026 23:53:10 +0200 Subject: [PATCH] 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) --- web/src/app.tsx | 2 - web/src/components/ui/drawer.tsx | 66 ++++++++++++++++++++++ web/src/i18n/en.json | 2 +- web/src/i18n/sv.json | 2 +- web/src/objects/object-detail-drawer.tsx | 44 +++++++++++++++ web/src/objects/objects-page.test.tsx | 68 ++++++++++++++++++---- web/src/objects/objects-page.tsx | 72 +++++++++++++++++++----- web/src/objects/objects-table.tsx | 5 ++ web/src/objects/select-prompt.tsx | 11 ---- 9 files changed, 234 insertions(+), 38 deletions(-) create mode 100644 web/src/components/ui/drawer.tsx create mode 100644 web/src/objects/object-detail-drawer.tsx delete mode 100644 web/src/objects/select-prompt.tsx diff --git a/web/src/app.tsx b/web/src/app.tsx index 1eaa60d..54205c4 100644 --- a/web/src/app.tsx +++ b/web/src/app.tsx @@ -6,7 +6,6 @@ import { LoginPage } from "./auth/login-page"; import { AppShell } from "./shell/app-shell"; import { ObjectsPage } from "./objects/objects-page"; import { ObjectDetail } from "./objects/object-detail"; -import { SelectPrompt } from "./objects/select-prompt"; import { SearchPage } from "./search/search-page"; import { SelectSearchPrompt } from "./search/select-search-prompt"; import { VocabulariesPage } from "./vocab/vocabularies-page"; @@ -46,7 +45,6 @@ export function App() { } /> }> - } /> } /> ; +} + +function DrawerTrigger({ ...props }: DrawerPrimitive.Trigger.Props) { + return ; +} + +function DrawerPortal({ ...props }: DrawerPrimitive.Portal.Props) { + return ; +} + +function DrawerBackdrop({ className, ...props }: DrawerPrimitive.Backdrop.Props) { + return ( + + ); +} + +function DrawerContent({ className, children, ...props }: DrawerPrimitive.Popup.Props) { + return ( + + + + + {children} + + + + ); +} + +function DrawerClose({ ...props }: DrawerPrimitive.Close.Props) { + return ; +} + +function DrawerViewport({ ...props }: DrawerPrimitive.Viewport.Props) { + return ; +} + +export { + Drawer, + DrawerBackdrop, + DrawerClose, + DrawerContent, + DrawerPortal, + DrawerTrigger, + DrawerViewport, +}; diff --git a/web/src/i18n/en.json b/web/src/i18n/en.json index d08b7e6..3d0a309 100644 --- a/web/src/i18n/en.json +++ b/web/src/i18n/en.json @@ -6,7 +6,7 @@ "fieldsLabels": { "objectNumber": "Object number", "objectName": "Name", "count": "Number of objects", "briefDescription": "Brief description", "currentLocation": "Current location", "currentOwner": "Current owner", "recorder": "Recorder", "recordingDate": "Recording date", "visibility": "Visibility", "flexible": "Catalogue fields" }, "visibility": { "draft": "Draft", "internal": "Internal", "public": "Public" }, "form": { "selectPlaceholder": "— select —", "create": "Create object", "save": "Save", "cancel": "Cancel", "visibility": "Visibility", "draft": "Draft", "internal": "Internal", "required": "This field is required", "rejected": "The server rejected the changes — check required and referenced fields", "fieldRejected": "The field \"{{field}}\" was rejected — check its value", "flexibleHeading": "Catalogue fields" }, - "actions": { "edit": "Edit", "delete": "Delete", "rename": "Rename", "save": "Save", "confirmDelete": "Delete this object? This cannot be undone.", "confirmDeleteTerm": "Delete this term? This cannot be undone.", "confirmDeleteAuthority": "Delete this authority? This cannot be undone.", "confirmDeleteField": "Delete this field definition? This cannot be undone.", "confirmDeleteVocabulary": "Delete this vocabulary? This cannot be undone.", "inUse": "Can't delete — used by {{count}} object(s). Clear those fields first." }, + "actions": { "edit": "Edit", "delete": "Delete", "rename": "Rename", "save": "Save", "closeDetail": "Close detail", "confirmDelete": "Delete this object? This cannot be undone.", "confirmDeleteTerm": "Delete this term? This cannot be undone.", "confirmDeleteAuthority": "Delete this authority? This cannot be undone.", "confirmDeleteField": "Delete this field definition? This cannot be undone.", "confirmDeleteVocabulary": "Delete this vocabulary? This cannot be undone.", "inUse": "Can't delete — used by {{count}} object(s). Clear those fields first." }, "labels": { "label": "Label", "externalUri": "External URI (optional)" }, "vocab": { "newVocabulary": "New vocabulary", "key": "Key", diff --git a/web/src/i18n/sv.json b/web/src/i18n/sv.json index e8cab48..e170faa 100644 --- a/web/src/i18n/sv.json +++ b/web/src/i18n/sv.json @@ -6,7 +6,7 @@ "fieldsLabels": { "objectNumber": "Föremålsnummer", "objectName": "Namn", "count": "Antal föremål", "briefDescription": "Kort beskrivning", "currentLocation": "Nuvarande plats", "currentOwner": "Nuvarande ägare", "recorder": "Registrerad av", "recordingDate": "Registreringsdatum", "visibility": "Synlighet", "flexible": "Katalogfält" }, "visibility": { "draft": "Utkast", "internal": "Intern", "public": "Publik" }, "form": { "selectPlaceholder": "— välj —", "create": "Skapa föremål", "save": "Spara", "cancel": "Avbryt", "visibility": "Synlighet", "draft": "Utkast", "internal": "Intern", "required": "Fältet är obligatoriskt", "rejected": "Servern avvisade ändringarna — kontrollera obligatoriska och refererade fält", "fieldRejected": "Fältet \"{{field}}\" avvisades — kontrollera värdet", "flexibleHeading": "Katalogfält" }, - "actions": { "edit": "Redigera", "delete": "Ta bort", "rename": "Byt namn", "save": "Spara", "confirmDelete": "Ta bort detta föremål? Detta kan inte ångras.", "confirmDeleteTerm": "Ta bort denna term? Detta kan inte ångras.", "confirmDeleteAuthority": "Ta bort denna auktoritet? Detta kan inte ångras.", "confirmDeleteField": "Ta bort denna fältdefinition? Detta kan inte ångras.", "confirmDeleteVocabulary": "Ta bort denna vokabulär? Detta kan inte ångras.", "inUse": "Kan inte ta bort — används av {{count}} föremål. Rensa de fälten först." }, + "actions": { "edit": "Redigera", "delete": "Ta bort", "rename": "Byt namn", "save": "Spara", "closeDetail": "Stäng detalj", "confirmDelete": "Ta bort detta föremål? Detta kan inte ångras.", "confirmDeleteTerm": "Ta bort denna term? Detta kan inte ångras.", "confirmDeleteAuthority": "Ta bort denna auktoritet? Detta kan inte ångras.", "confirmDeleteField": "Ta bort denna fältdefinition? Detta kan inte ångras.", "confirmDeleteVocabulary": "Ta bort denna vokabulär? Detta kan inte ångras.", "inUse": "Kan inte ta bort — används av {{count}} föremål. Rensa de fälten först." }, "labels": { "label": "Etikett", "externalUri": "Extern URI (valfritt)" }, "vocab": { "newVocabulary": "Ny vokabulär", "key": "Nyckel", diff --git a/web/src/objects/object-detail-drawer.tsx b/web/src/objects/object-detail-drawer.tsx new file mode 100644 index 0000000..1d99ab8 --- /dev/null +++ b/web/src/objects/object-detail-drawer.tsx @@ -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 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 ( + { + if (!next) onClose(); + }} + swipeDirection="right" + > + +
+ + +
+
+ +
+
+
+ ); +} diff --git a/web/src/objects/objects-page.test.tsx b/web/src/objects/objects-page.test.tsx index 0ad92bc..d753d2b 100644 --- a/web/src/objects/objects-page.test.tsx +++ b/web/src/objects/objects-page.test.tsx @@ -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 ( }> - } /> } /> ); } -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(); }); diff --git a/web/src/objects/objects-page.tsx b/web/src/objects/objects-page.tsx index ff28b48..bc5c777 100644 --- a/web/src/objects/objects-page.tsx +++ b/web/src/objects/objects-page.tsx @@ -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 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 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 ( -
-
- + const closeDetail = () => navigate(`/objects?${searchParams}`); + + const table = ( +
+ +
+ ); + + if (isWide) { + return ( +
+ {table} + {open && ( +
+
+ +
+
+ +
+
+ )}
- {detail && ( -
- -
+ ); + } + + // Narrow: the detail lives in a Drawer, lazy-loaded so Base UI's drawer code stays + // out of the main entry chunk. + return ( +
+ {table} + {open && ( + + + )}
); diff --git a/web/src/objects/objects-table.tsx b/web/src/objects/objects-table.tsx index 4da6892..3fa378a 100644 --- a/web/src/objects/objects-table.tsx +++ b/web/src/objects/objects-table.tsx @@ -246,8 +246,13 @@ export function ObjectsTable() { return ( 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" }`} diff --git a/web/src/objects/select-prompt.tsx b/web/src/objects/select-prompt.tsx deleted file mode 100644 index 0cf2930..0000000 --- a/web/src/objects/select-prompt.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { useTranslation } from "react-i18next"; - -export function SelectPrompt() { - const { t } = useTranslation(); - - return ( -
- {t("objects.selectPrompt")} -
- ); -}