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")}
-
- );
-}