fix(ui): make PageBack destinations deterministic

This commit is contained in:
Quick
2026-05-26 21:18:39 -04:00
parent a8ef653c11
commit 602fc5b413
10 changed files with 86 additions and 26 deletions
+35 -3
View File
@@ -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(
<MemoryRouter>
@@ -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(
<MemoryRouter>
<PageBack />
</MemoryRouter>,
);
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(
<MemoryRouter>
<PageBack />
@@ -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(
<MemoryRouter>
<PageBack to="/collections" preferHistory={false} />
</MemoryRouter>,
);
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(
<MemoryRouter>
+22 -3
View File
@@ -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 (
<button
type="button"
aria-label={label}
onClick={() => navigate(-1)}
onClick={goBack}
className={`glass text-foreground hover:bg-accent ${position} z-20 flex items-center justify-center rounded-full p-1.5 shadow-md transition-colors`}
>
<ChevronLeft className="size-5" />
+6 -6
View File
@@ -32,8 +32,8 @@ export default function CollectionEditor() {
if (id && !collection && !isLoading) {
return (
<div className="page-shell relative space-y-4 py-4 sm:py-6">
<PageBack />
<Card className="surface-panel rounded-[1.7rem] border-0 shadow-none">
<PageBack to="/collections" preferHistory={false} />
<Card className="surface-panel mt-10 rounded-[1.7rem] border-0 shadow-none sm:mt-12">
<CardHeader>
<CardTitle>Collection not found</CardTitle>
<CardDescription>The selected collection could not be loaded.</CardDescription>
@@ -46,8 +46,8 @@ export default function CollectionEditor() {
if (collection && isImportedCollection(collection)) {
return (
<div className="page-shell relative space-y-6 py-4 sm:py-6">
<PageBack />
<div>
<PageBack to="/collections" preferHistory={false} />
<div className="mt-10 sm:mt-12">
<h1 className="page-title text-[clamp(2rem,4vw,3rem)]">{collection.name}</h1>
<p className="page-subtitle mt-1 text-sm sm:text-base">
Edit what's local — name, libraries, sharing. Source-managed details (URL, schedule,
@@ -68,8 +68,8 @@ export default function CollectionEditor() {
if (collection && collection.collection_type === "manual") {
return (
<div className="page-shell relative space-y-6 py-4 sm:py-6">
<PageBack />
<div>
<PageBack to="/collections" preferHistory={false} />
<div className="mt-10 sm:mt-12">
<h1 className="page-title text-[clamp(2rem,4vw,3rem)]">Edit {collection.name}</h1>
<p className="page-subtitle mt-1 text-sm sm:text-base">
Manual collections are curated by adding titles directly.
+1 -1
View File
@@ -79,7 +79,7 @@ export default function PersonDetail() {
{/* Person Header */}
<section className="page-shell relative pt-8 pb-6 sm:pt-10 sm:pb-8">
<PageBack />
<div className="flex flex-col gap-6 lg:flex-row lg:gap-8">
<div className="mt-10 flex flex-col gap-6 sm:mt-12 lg:flex-row lg:gap-8">
{/* Photo */}
<div className="shrink-0 self-start">
<div className="media-card-image aspect-[2/3] w-[140px] overflow-hidden rounded-lg sm:w-[180px]">
+1 -1
View File
@@ -210,7 +210,7 @@ export default function ProfileCustomizeHome() {
return (
<div className="relative mx-auto max-w-3xl p-6">
<PageBack />
<div className="flex items-center justify-between border-b border-white/10 pb-3">
<div className="mt-10 flex items-center justify-between border-b border-white/10 pb-3 sm:mt-12">
<h1 className="text-base font-semibold">Customize home</h1>
<div className="flex gap-2">
<button
+2 -2
View File
@@ -77,8 +77,8 @@ export default function RecommendationsSection() {
return (
<div className="relative space-y-6 px-4 pt-6 pb-12 sm:px-6 lg:px-10 xl:px-12">
<PageBack />
<div className="flex flex-col gap-1.5">
<PageBack to="/recommendations" preferHistory={false} />
<div className="mt-10 flex flex-col gap-1.5 sm:mt-12">
<h1 className="text-foreground text-2xl font-bold tracking-tight sm:text-3xl">{title}</h1>
{data && data.items.length > 0 && (
<p className="text-muted-foreground text-sm">
+4 -4
View File
@@ -84,8 +84,8 @@ export default function RequestBrowse({ kind }: RequestBrowseProps) {
if (browse.isError && (browse.error as { status?: number }).status === 404) {
return (
<div className="relative space-y-4 py-10 text-center">
<PageBack />
<p className="text-foreground text-lg font-semibold">
<PageBack to="/requests" preferHistory={false} />
<p className="text-foreground mt-10 text-lg font-semibold sm:mt-12">
{kind === "studio" ? "Studio" : kind === "network" ? "Network" : "Genre"} not found.
</p>
</div>
@@ -94,8 +94,8 @@ export default function RequestBrowse({ kind }: RequestBrowseProps) {
return (
<div className="relative space-y-6 py-6 sm:py-8">
<PageBack />
<div className="space-y-4 px-4 sm:px-6 lg:px-10 xl:px-12">
<PageBack to="/requests" preferHistory={false} />
<div className="mt-10 space-y-4 px-4 sm:mt-12 sm:px-6 lg:px-10 xl:px-12">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-4">
<BrowseHeaderTile browse={browse.data} kind={kind} fallback={title} />
+5 -3
View File
@@ -40,8 +40,10 @@ export default function RequestDetail() {
if (detail.isError || !detail.data) {
return (
<div className="page-shell relative space-y-3 py-12 text-center">
<PageBack />
<p className="text-foreground text-base font-semibold">Couldn't load this title.</p>
<PageBack to="/requests" />
<p className="text-foreground mt-10 text-base font-semibold sm:mt-12">
Couldn't load this title.
</p>
<p className="text-muted-foreground text-sm">
The TMDB record may be temporarily unavailable.
</p>
@@ -58,7 +60,7 @@ export default function RequestDetail() {
<div>
<DetailHero
title={item.title}
topNav={<PageBack />}
topNav={<PageBack to="/requests" />}
context={<RequestContext mediaType={mediaType} />}
studioLabel={studioLabel}
backdropUrl={backdropUrl}
+1 -1
View File
@@ -156,7 +156,7 @@ export default function SettingsLayout() {
return (
<div className="min-h-[100dvh]">
<main className="page-shell-wide relative flex min-h-[100dvh] flex-col py-4 sm:py-6">
<PageBack floating />
<PageBack to="/" preferHistory={false} floating />
<div className="page-header mt-10 gap-5 sm:mt-12">
<div className="min-w-0 space-y-3">
<h1 className="page-title text-[clamp(2rem,4vw,3rem)]">Settings</h1>
+9 -2
View File
@@ -118,11 +118,12 @@ export default function SmartCollectionWizard(wizard: SmartCollectionWizardProps
}, []);
const headerTitle = isEdit ? draft.title || "Edit Collection" : "New Collection";
const backTarget = wizard.mode === "user" ? "/collections" : adminBackHref(wizard);
const adminLibraries = wizard.mode === "admin" ? wizard.libraries : [];
return (
<div className="page-shell-wide relative space-y-6 py-4 sm:py-6">
<PageBack />
<PageBack to={backTarget} preferHistory={false} />
<WizardHeader title={headerTitle} step={step} isEdit={isEdit} />
{step === 1 ? (
@@ -171,6 +172,12 @@ function smartUserDraft(collection: Collection | null): CollectionBuilderValue {
: createCollectionBuilderValue({ ...value, collection_type: "smart" });
}
function adminBackHref(props: AdminModeProps): string {
return props.initialLibraryId
? `/admin/collections?libraryId=${props.initialLibraryId}`
: "/admin/collections";
}
function WizardHeader({
title,
step,
@@ -181,7 +188,7 @@ function WizardHeader({
isEdit: boolean;
}) {
return (
<div className="flex flex-wrap items-end justify-between gap-4">
<div className="mt-10 flex flex-wrap items-end justify-between gap-4 sm:mt-12">
<div>
<h1 className="page-title text-[clamp(1.75rem,3vw,2.5rem)]">{title}</h1>
<p className="page-subtitle mt-1 text-sm">