From f7cae960c493ea8e2d6f4131bd770b946dd1bc5b Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Tue, 26 May 2026 20:18:05 -0400 Subject: [PATCH 1/3] feat(ui): add shared PageBack component for consistent back navigation Replace the eight inconsistent back affordances across user-facing pages with a single absolute-positioned chevron pill, so the control lives in the same screen position regardless of title length or hero content. DetailBreadcrumb keeps its textual hierarchy path but no longer owns the back chevron; PageBack does. DetailHero gains a topNav slot consumed by Movie/Series/Season/Episode/Request detail pages. Non-hero pages drop their bespoke back buttons and add PageBack inside a relative wrapper. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/src/components/PageBack.test.tsx | 73 +++++++++++++++++++ web/src/components/PageBack.tsx | 20 +++++ web/src/pages/CollectionEditor.tsx | 58 +++++---------- web/src/pages/ItemDetail/DetailHero.tsx | 3 + .../pages/ItemDetail/EpisodeContent.test.tsx | 6 +- web/src/pages/ItemDetail/EpisodeContent.tsx | 2 + web/src/pages/ItemDetail/MovieContent.tsx | 2 + web/src/pages/ItemDetail/SeasonContent.tsx | 2 + web/src/pages/ItemDetail/SeriesContent.tsx | 2 + .../components/DetailBreadcrumb.tsx | 62 +++++++--------- web/src/pages/PersonDetail.tsx | 4 +- web/src/pages/ProfileCustomizeHome.tsx | 4 +- web/src/pages/RecommendationsSection.tsx | 31 +++----- web/src/pages/RequestBrowse.tsx | 22 ++---- web/src/pages/RequestDetail.tsx | 29 ++------ web/src/pages/SettingsLayout.test.tsx | 5 +- web/src/pages/SettingsLayout.tsx | 12 +-- web/src/pages/SmartCollectionWizard.tsx | 46 ++++-------- 18 files changed, 201 insertions(+), 182 deletions(-) create mode 100644 web/src/components/PageBack.test.tsx create mode 100644 web/src/components/PageBack.tsx diff --git a/web/src/components/PageBack.test.tsx b/web/src/components/PageBack.test.tsx new file mode 100644 index 00000000..27077150 --- /dev/null +++ b/web/src/components/PageBack.test.tsx @@ -0,0 +1,73 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router"; +import { describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + navigate: vi.fn(), +})); + +vi.mock("react-router", async () => { + const actual = await vi.importActual("react-router"); + return { + ...actual, + useNavigate: () => mocks.navigate, + }; +}); + +import PageBack from "./PageBack"; + +describe("PageBack", () => { + it("renders a button with the default 'Go back' aria-label", () => { + render( + + + , + ); + + expect(screen.getByRole("button", { name: "Go back" })).toBeInTheDocument(); + }); + + it("uses a custom label when provided", () => { + render( + + + , + ); + + expect(screen.getByRole("button", { name: "Return to library" })).toBeInTheDocument(); + }); + + it("calls navigate(-1) on click", async () => { + mocks.navigate.mockClear(); + render( + + + , + ); + + await userEvent.click(screen.getByRole("button", { name: "Go back" })); + + expect(mocks.navigate).toHaveBeenCalledTimes(1); + expect(mocks.navigate).toHaveBeenCalledWith(-1); + }); + + it("applies the documented positioning and glass-subtle styling", () => { + render( + + + , + ); + + const button = screen.getByRole("button", { name: "Go back" }); + expect(button).toHaveClass( + "glass-subtle", + "absolute", + "top-4", + "left-4", + "z-20", + "rounded-full", + "p-1.5", + ); + }); +}); diff --git a/web/src/components/PageBack.tsx b/web/src/components/PageBack.tsx new file mode 100644 index 00000000..d6b87565 --- /dev/null +++ b/web/src/components/PageBack.tsx @@ -0,0 +1,20 @@ +import { ChevronLeft } from "lucide-react"; +import { useNavigate } from "react-router"; + +interface PageBackProps { + label?: string; +} + +export default function PageBack({ label = "Go back" }: PageBackProps) { + const navigate = useNavigate(); + return ( + + ); +} diff --git a/web/src/pages/CollectionEditor.tsx b/web/src/pages/CollectionEditor.tsx index df6343f4..6b0b8769 100644 --- a/web/src/pages/CollectionEditor.tsx +++ b/web/src/pages/CollectionEditor.tsx @@ -1,8 +1,7 @@ -import { Link, useNavigate, useParams } from "react-router"; -import { ArrowLeft } from "lucide-react"; +import { useNavigate, useParams } from "react-router"; import type { Collection, UserCollectionType } from "@/api/types"; -import { Button } from "@/components/ui/button"; +import PageBack from "@/components/PageBack"; import { Card, CardHeader, CardDescription, CardTitle } from "@/components/ui/card"; import { useCollections } from "@/hooks/queries/collections"; @@ -32,13 +31,8 @@ export default function CollectionEditor() { if (id && !collection && !isLoading) { return ( -
- +
+ Collection not found @@ -51,21 +45,14 @@ export default function CollectionEditor() { if (collection && isImportedCollection(collection)) { return ( -
-
- -
-

{collection.name}

-

- Edit what's local — name, libraries, sharing. Source-managed details (URL, schedule, - item ordering) are locked. -

-
+
+ +
+

{collection.name}

+

+ Edit what's local — name, libraries, sharing. Source-managed details (URL, schedule, + item ordering) are locked. +

-
- -
-

Edit {collection.name}

-

- Manual collections are curated by adding titles directly. -

-
+
+ +
+

Edit {collection.name}

+

+ Manual collections are curated by adding titles directly. +

navigate("/collections")} />
diff --git a/web/src/pages/ItemDetail/DetailHero.tsx b/web/src/pages/ItemDetail/DetailHero.tsx index 01cc0934..52169f41 100644 --- a/web/src/pages/ItemDetail/DetailHero.tsx +++ b/web/src/pages/ItemDetail/DetailHero.tsx @@ -22,6 +22,7 @@ interface DetailHeroProps { scoreRow?: ReactNode; crewLine?: ReactNode; variant?: "full" | "compact"; + topNav?: ReactNode; } export default function DetailHero({ @@ -45,6 +46,7 @@ export default function DetailHero({ scoreRow, crewLine, variant = "full", + topNav, }: DetailHeroProps) { const [backdropLoaded, setBackdropLoaded] = useState(false); const [posterLoaded, setPosterLoaded] = useState(false); @@ -65,6 +67,7 @@ export default function DetailHero({ return (
+ {topNav} {(backdropUrl || backdropPlaceholder) && (
{ mocks.useRating.mockReturnValue({ data: { rating: 3, rated_at: "2026-03-22T00:00:00Z" } }); }); - it("links the season breadcrumb and back button to the resolved season page", () => { + it("links the season breadcrumb segment to the resolved season page", () => { const markup = renderToStaticMarkup( @@ -233,7 +233,7 @@ describe("EpisodeContent", () => { ); expect(countOccurrences(markup, 'href="/item/series-1"')).toBe(1); - expect(countOccurrences(markup, 'href="/item/season-1"')).toBe(2); + expect(countOccurrences(markup, 'href="/item/season-1"')).toBe(1); expect(markup).toContain(">Season 1<"); }); @@ -258,7 +258,7 @@ describe("EpisodeContent", () => { , ); - expect(countOccurrences(markup, 'href="/item/season-99"')).toBe(2); + expect(countOccurrences(markup, 'href="/item/season-99"')).toBe(1); expect(markup).toContain(">Season 99<"); }); diff --git a/web/src/pages/ItemDetail/EpisodeContent.tsx b/web/src/pages/ItemDetail/EpisodeContent.tsx index aaa380f1..1aa799a9 100644 --- a/web/src/pages/ItemDetail/EpisodeContent.tsx +++ b/web/src/pages/ItemDetail/EpisodeContent.tsx @@ -16,6 +16,7 @@ import CrewList from "@/components/CrewList"; import DownloadVersionPicker from "@/components/DownloadVersionPicker"; import EditMetadataDialog from "@/components/EditMetadataDialog"; import MediaLocations from "@/components/MediaLocations"; +import PageBack from "@/components/PageBack"; import EpisodeCarousel from "./components/EpisodeCarousel"; import DetailHero from "./DetailHero"; import MetadataBadges from "./components/MetadataBadges"; @@ -221,6 +222,7 @@ export default function EpisodeContent({ item }: { item: ItemDetail & { type: "e
} context={
diff --git a/web/src/pages/ItemDetail/MovieContent.tsx b/web/src/pages/ItemDetail/MovieContent.tsx index e78ea0d5..e83acc4d 100644 --- a/web/src/pages/ItemDetail/MovieContent.tsx +++ b/web/src/pages/ItemDetail/MovieContent.tsx @@ -16,6 +16,7 @@ import DownloadVersionPicker from "@/components/DownloadVersionPicker"; import EditMetadataDialog from "@/components/EditMetadataDialog"; import MediaLocations from "@/components/MediaLocations"; import MatchItemDialog from "@/components/MatchItemDialog"; +import PageBack from "@/components/PageBack"; import RecommendationGrid from "@/components/RecommendationGrid"; import DetailHero from "./DetailHero"; import MetadataBadges from "./components/MetadataBadges"; @@ -179,6 +180,7 @@ export default function MovieContent({ item }: { item: ItemDetail & { type: "mov
} context="Movie" studioLabel={firstStudio} backdropUrl={item.backdrop_url} diff --git a/web/src/pages/ItemDetail/SeasonContent.tsx b/web/src/pages/ItemDetail/SeasonContent.tsx index 06910754..6a5c2347 100644 --- a/web/src/pages/ItemDetail/SeasonContent.tsx +++ b/web/src/pages/ItemDetail/SeasonContent.tsx @@ -8,6 +8,7 @@ import { useAuth } from "@/hooks/useAuth"; import CastCarousel from "@/components/CastCarousel"; import CrewList from "@/components/CrewList"; import EditMetadataDialog from "@/components/EditMetadataDialog"; +import PageBack from "@/components/PageBack"; import DetailHero from "./DetailHero"; import MetadataBadges from "./components/MetadataBadges"; import ActionBar from "./components/ActionBar"; @@ -82,6 +83,7 @@ export default function SeasonContent({ item }: { item: ItemDetail & { type: "se } context={breadcrumb} backdropUrl={item.backdrop_url} backdropThumbhash={item.backdrop_thumbhash} diff --git a/web/src/pages/ItemDetail/SeriesContent.tsx b/web/src/pages/ItemDetail/SeriesContent.tsx index de46882a..dbe0683f 100644 --- a/web/src/pages/ItemDetail/SeriesContent.tsx +++ b/web/src/pages/ItemDetail/SeriesContent.tsx @@ -14,6 +14,7 @@ import CastCarousel from "@/components/CastCarousel"; import CrewList from "@/components/CrewList"; import EditMetadataDialog from "@/components/EditMetadataDialog"; import MatchItemDialog from "@/components/MatchItemDialog"; +import PageBack from "@/components/PageBack"; import RecommendationGrid from "@/components/RecommendationGrid"; import DetailHero from "./DetailHero"; import SeasonCarousel from "./SeasonCarousel"; @@ -116,6 +117,7 @@ export default function SeriesContent({ item }: { item: ItemDetail & { type: "se
} context="Series" studioLabel={firstNetwork} backdropUrl={item.backdrop_url} diff --git a/web/src/pages/ItemDetail/components/DetailBreadcrumb.tsx b/web/src/pages/ItemDetail/components/DetailBreadcrumb.tsx index 259c9fc5..95eb7627 100644 --- a/web/src/pages/ItemDetail/components/DetailBreadcrumb.tsx +++ b/web/src/pages/ItemDetail/components/DetailBreadcrumb.tsx @@ -1,5 +1,5 @@ import { Link } from "react-router"; -import { ChevronLeft, ChevronRight } from "lucide-react"; +import { ChevronRight } from "lucide-react"; interface BreadcrumbSegment { label: string; @@ -13,44 +13,32 @@ interface DetailBreadcrumbProps { export default function DetailBreadcrumb({ segments }: DetailBreadcrumbProps) { if (segments.length === 0) return null; - const backSegment = [...segments].reverse().find((segment) => segment.href); - return ( ); diff --git a/web/src/pages/PersonDetail.tsx b/web/src/pages/PersonDetail.tsx index 44905f94..60719075 100644 --- a/web/src/pages/PersonDetail.tsx +++ b/web/src/pages/PersonDetail.tsx @@ -8,6 +8,7 @@ import { createEmptyQueryDefinition } from "@/api/types"; import type { CatalogSearchState } from "@/pages/catalogSearchParams"; import EditPersonDialog from "@/components/EditPersonDialog"; import ItemGrid from "@/components/ItemGrid"; +import PageBack from "@/components/PageBack"; import { Button } from "@/components/ui/button"; import { useCatalogWindow } from "@/hooks/queries/catalog"; import { personKeys } from "@/hooks/queries/keys"; @@ -76,7 +77,8 @@ export default function PersonDetail() { return (
{/* Person Header */} -
+
+
{/* Photo */}
diff --git a/web/src/pages/ProfileCustomizeHome.tsx b/web/src/pages/ProfileCustomizeHome.tsx index 460d01af..2f47694e 100644 --- a/web/src/pages/ProfileCustomizeHome.tsx +++ b/web/src/pages/ProfileCustomizeHome.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import PageBack from "@/components/PageBack"; import ProfileSectionRow from "@/components/ProfileSectionRow"; import RecipeGalleryModal from "@/components/RecipeGallery/RecipeGalleryModal"; import RecipeConfigDrawer from "@/components/RecipeGallery/RecipeConfigDrawer"; @@ -207,7 +208,8 @@ export default function ProfileCustomizeHome() { } return ( -
+
+

Customize home

diff --git a/web/src/pages/RecommendationsSection.tsx b/web/src/pages/RecommendationsSection.tsx index 1f26d3a4..f0fa428d 100644 --- a/web/src/pages/RecommendationsSection.tsx +++ b/web/src/pages/RecommendationsSection.tsx @@ -1,6 +1,7 @@ -import { Link, useParams } from "react-router"; -import { ArrowLeft, RefreshCw, Sparkles } from "lucide-react"; +import { useParams } from "react-router"; +import { RefreshCw, Sparkles } from "lucide-react"; +import PageBack from "@/components/PageBack"; import SectionItemCard from "@/components/SectionItemCard"; import { Skeleton } from "@/components/ui/skeleton"; import { useRecommendationSection } from "@/hooks/queries/recommendations"; @@ -75,23 +76,15 @@ export default function RecommendationsSection() { useDocumentTitle(title); return ( -
-
- - - Recommendations - -
-

{title}

- {data && data.items.length > 0 && ( -

- {data.items.length} {data.items.length === 1 ? "title" : "titles"} -

- )} -
+
+ +
+

{title}

+ {data && data.items.length > 0 && ( +

+ {data.items.length} {data.items.length === 1 ? "title" : "titles"} +

+ )}
{isLoading ? ( diff --git a/web/src/pages/RequestBrowse.tsx b/web/src/pages/RequestBrowse.tsx index a85b967f..448447d8 100644 --- a/web/src/pages/RequestBrowse.tsx +++ b/web/src/pages/RequestBrowse.tsx @@ -1,5 +1,5 @@ -import { Link, useParams, useSearchParams } from "react-router"; -import { ArrowLeft } from "lucide-react"; +import { useParams, useSearchParams } from "react-router"; +import PageBack from "@/components/PageBack"; import RequestPosterCard from "@/components/RequestPosterCard"; import { Button } from "@/components/ui/button"; import { @@ -83,29 +83,19 @@ export default function RequestBrowse({ kind }: RequestBrowseProps) { if (browse.isError && (browse.error as { status?: number }).status === 404) { return ( -
+
+

{kind === "studio" ? "Studio" : kind === "network" ? "Network" : "Genre"} not found.

- - Back to Requests -
); } return ( -
+
+
- - Back to Requests -
diff --git a/web/src/pages/RequestDetail.tsx b/web/src/pages/RequestDetail.tsx index 304dee77..2585cc2a 100644 --- a/web/src/pages/RequestDetail.tsx +++ b/web/src/pages/RequestDetail.tsx @@ -1,7 +1,8 @@ -import { useNavigate, useParams } from "react-router"; -import { ArrowLeft, Check, Clock, Loader2, Plus, Star } from "lucide-react"; +import { useParams } from "react-router"; +import { Check, Clock, Loader2, Plus, Star } from "lucide-react"; import CastCarousel from "@/components/CastCarousel"; import MediaCarousel from "@/components/MediaCarousel"; +import PageBack from "@/components/PageBack"; import RequestPosterCard from "@/components/RequestPosterCard"; import DetailHero from "@/pages/ItemDetail/DetailHero"; import { Button } from "@/components/ui/button"; @@ -23,7 +24,6 @@ import { } from "@/lib/mediaRequests"; export default function RequestDetail() { - const navigate = useNavigate(); const params = useParams<{ mediaType: string; tmdbId: string }>(); const mediaType = (params.mediaType === "series" ? "series" : "movie") as "movie" | "series"; const tmdbID = Number(params.tmdbId) || 0; @@ -39,17 +39,12 @@ export default function RequestDetail() { if (detail.isError || !detail.data) { return ( -
+
+

Couldn't load this title.

The TMDB record may be temporarily unavailable.

-
- -
); } @@ -63,6 +58,7 @@ export default function RequestDetail() {
} context={} studioLabel={studioLabel} backdropUrl={backdropUrl} @@ -79,7 +75,6 @@ export default function RequestDetail() { createRequest.isPending && createRequest.variables?.tmdb_id === item.tmdb_id } onRequest={() => createRequest.mutate(requestInputFromMediaResult(item))} - onBack={() => navigate(-1)} /> } /> @@ -198,12 +193,10 @@ function RequestActions({ item, isSubmitting, onRequest, - onBack, }: { item: RequestMediaDetail; isSubmitting: boolean; onRequest: () => void; - onBack: () => void; }) { const requestable = item.request.requestable; const statusLabel = item.request.status ? formatRequestStatus(item.request.status) : null; @@ -213,16 +206,6 @@ function RequestActions({ return (
- - {requestable ? ( -
-
-

{title}

-

- {step === 1 - ? "Tune the filters until the cards below show the collection you want." - : isEdit - ? "Update naming, artwork, and sharing for this collection." - : "Give your new collection a name, artwork, and sharing rules."} -

-
- +
+
+

{title}

+

+ {step === 1 + ? "Tune the filters until the cards below show the collection you want." + : isEdit + ? "Update naming, artwork, and sharing for this collection." + : "Give your new collection a name, artwork, and sharing rules."} +

+
); } From a8ef653c11ff70d3a65bb7b6292a03805cbdbc78 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Tue, 26 May 2026 20:51:52 -0400 Subject: [PATCH 2/3] feat(ui): add floating variant to PageBack for sticky nav - Add `floating` prop to pin PageBack to viewport on lg+ screens - Switch styling from glass-subtle to glass with shadow for better contrast - Use floating variant on SettingsLayout --- web/src/components/PageBack.test.tsx | 17 ++++++++++++++--- web/src/components/PageBack.tsx | 13 +++++++++++-- web/src/pages/SettingsLayout.tsx | 4 ++-- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/web/src/components/PageBack.test.tsx b/web/src/components/PageBack.test.tsx index 27077150..149391e1 100644 --- a/web/src/components/PageBack.test.tsx +++ b/web/src/components/PageBack.test.tsx @@ -52,7 +52,7 @@ describe("PageBack", () => { expect(mocks.navigate).toHaveBeenCalledWith(-1); }); - it("applies the documented positioning and glass-subtle styling", () => { + it("applies the documented positioning and glass styling", () => { render( @@ -61,13 +61,24 @@ describe("PageBack", () => { const button = screen.getByRole("button", { name: "Go back" }); expect(button).toHaveClass( - "glass-subtle", + "glass", "absolute", "top-4", - "left-4", + "left-2", "z-20", "rounded-full", "p-1.5", ); }); + + it("pins to the viewport on lg+ when floating is set", () => { + render( + + + , + ); + + const button = screen.getByRole("button", { name: "Go back" }); + expect(button).toHaveClass("lg:fixed", "lg:left-[268px]"); + }); }); diff --git a/web/src/components/PageBack.tsx b/web/src/components/PageBack.tsx index d6b87565..345091ce 100644 --- a/web/src/components/PageBack.tsx +++ b/web/src/components/PageBack.tsx @@ -3,16 +3,25 @@ import { useNavigate } from "react-router"; interface PageBackProps { label?: string; + /** + * When true, pins the button to the viewport on lg+ so it stays visible + * while scrolling. The offset matches the app sidebar (260px) so the + * button sits just inside the page content area. + */ + floating?: boolean; } -export default function PageBack({ label = "Go back" }: PageBackProps) { +export default function PageBack({ label = "Go back", floating = false }: PageBackProps) { const navigate = useNavigate(); + const position = floating + ? "absolute top-4 left-2 sm:top-6 lg:fixed lg:left-[268px]" + : "absolute top-4 left-2 sm:top-6"; return ( diff --git a/web/src/pages/SettingsLayout.tsx b/web/src/pages/SettingsLayout.tsx index 128bc3d9..f31f683a 100644 --- a/web/src/pages/SettingsLayout.tsx +++ b/web/src/pages/SettingsLayout.tsx @@ -156,8 +156,8 @@ export default function SettingsLayout() { return (
- -
+ +

Settings

From 602fc5b4138abf7814dc98cf8642ef8f16b140e0 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Tue, 26 May 2026 21:18:39 -0400 Subject: [PATCH 3/3] fix(ui): make PageBack destinations deterministic --- web/src/components/PageBack.test.tsx | 38 ++++++++++++++++++++++-- web/src/components/PageBack.tsx | 25 ++++++++++++++-- web/src/pages/CollectionEditor.tsx | 12 ++++---- web/src/pages/PersonDetail.tsx | 2 +- web/src/pages/ProfileCustomizeHome.tsx | 2 +- web/src/pages/RecommendationsSection.tsx | 4 +-- web/src/pages/RequestBrowse.tsx | 8 ++--- web/src/pages/RequestDetail.tsx | 8 +++-- web/src/pages/SettingsLayout.tsx | 2 +- web/src/pages/SmartCollectionWizard.tsx | 11 +++++-- 10 files changed, 86 insertions(+), 26 deletions(-) diff --git a/web/src/components/PageBack.test.tsx b/web/src/components/PageBack.test.tsx index 149391e1..c6c8108c 100644 --- a/web/src/components/PageBack.test.tsx +++ b/web/src/components/PageBack.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ navigate: vi.fn(), @@ -18,6 +18,11 @@ vi.mock("react-router", async () => { import PageBack from "./PageBack"; describe("PageBack", () => { + afterEach(() => { + mocks.navigate.mockClear(); + window.history.replaceState(null, ""); + }); + it("renders a button with the default 'Go back' aria-label", () => { render( @@ -38,8 +43,21 @@ describe("PageBack", () => { expect(screen.getByRole("button", { name: "Return to library" })).toBeInTheDocument(); }); - it("calls navigate(-1) on click", async () => { - mocks.navigate.mockClear(); + it("falls back to the default route when there is no router history", async () => { + render( + + + , + ); + + await userEvent.click(screen.getByRole("button", { name: "Go back" })); + + expect(mocks.navigate).toHaveBeenCalledTimes(1); + expect(mocks.navigate).toHaveBeenCalledWith("/"); + }); + + it("uses browser history when a router history entry is available", async () => { + window.history.replaceState({ idx: 1 }, ""); render( @@ -52,6 +70,20 @@ describe("PageBack", () => { expect(mocks.navigate).toHaveBeenCalledWith(-1); }); + it("uses the explicit target when history preference is disabled", async () => { + window.history.replaceState({ idx: 1 }, ""); + render( + + + , + ); + + await userEvent.click(screen.getByRole("button", { name: "Go back" })); + + expect(mocks.navigate).toHaveBeenCalledTimes(1); + expect(mocks.navigate).toHaveBeenCalledWith("/collections"); + }); + it("applies the documented positioning and glass styling", () => { render( diff --git a/web/src/components/PageBack.tsx b/web/src/components/PageBack.tsx index 345091ce..a2814fd3 100644 --- a/web/src/components/PageBack.tsx +++ b/web/src/components/PageBack.tsx @@ -1,8 +1,10 @@ import { ChevronLeft } from "lucide-react"; -import { useNavigate } from "react-router"; +import { type To, useNavigate } from "react-router"; interface PageBackProps { label?: string; + to?: To; + preferHistory?: boolean; /** * When true, pins the button to the viewport on lg+ so it stays visible * while scrolling. The offset matches the app sidebar (260px) so the @@ -11,16 +13,33 @@ interface PageBackProps { floating?: boolean; } -export default function PageBack({ label = "Go back", floating = false }: PageBackProps) { +export default function PageBack({ + label = "Go back", + to = "/", + preferHistory = true, + floating = false, +}: PageBackProps) { const navigate = useNavigate(); const position = floating ? "absolute top-4 left-2 sm:top-6 lg:fixed lg:left-[268px]" : "absolute top-4 left-2 sm:top-6"; + + function goBack() { + const historyIndex = window.history.state?.idx; + + if (preferHistory && typeof historyIndex === "number" && historyIndex > 0) { + navigate(-1); + return; + } + + navigate(to); + } + return (