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