perf(web): smooth item sidebar collapse (#504)
This commit is contained in:
+131
-8
@@ -94,9 +94,13 @@
|
||||
--duration-glacial: 700ms;
|
||||
--duration-cinematic: 1200ms;
|
||||
--duration-drift: 2000ms;
|
||||
/* Sidebar immersion collapse — see `.sidebar-surface`. */
|
||||
--duration-sidebar-collapse: 300ms;
|
||||
|
||||
/* ── Motion: Easing ────────────────────────────────────────── */
|
||||
--ease-default: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
/* Long, flat tail — keeps the moving edge from appearing to step near the end. */
|
||||
--ease-sidebar-collapse: cubic-bezier(0.32, 0.72, 0, 1);
|
||||
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||
--ease-out: cubic-bezier(0, 0, 0.2, 1);
|
||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
@@ -260,14 +264,21 @@
|
||||
--duration-drift: 0ms;
|
||||
--duration-glacial: 0ms;
|
||||
--duration-slow: 150ms;
|
||||
--duration-sidebar-collapse: 0ms;
|
||||
--animate-ken-burns-a: none;
|
||||
--animate-ken-burns-b: none;
|
||||
}
|
||||
.ambient-glow {
|
||||
transition: none;
|
||||
}
|
||||
.sidebar-transition,
|
||||
.main-transition {
|
||||
/* Collapsing the sidebar becomes an instant state change: the frame, the
|
||||
content under it, and everything cross-fading inside all stop animating. */
|
||||
.sidebar-surface,
|
||||
.sidebar-inner,
|
||||
.sidebar-fade,
|
||||
.sidebar-row-shift,
|
||||
.sidebar-main-stage,
|
||||
.impersonation-banner {
|
||||
transition: none;
|
||||
}
|
||||
.player-breathe,
|
||||
@@ -857,13 +868,81 @@
|
||||
opacity var(--duration-cinematic) var(--ease-gentle);
|
||||
}
|
||||
|
||||
/* ── Sidebar Immersion Transitions ─────────────────────────── */
|
||||
.sidebar-transition {
|
||||
transition: width var(--duration-normal) var(--ease-default);
|
||||
/* ── Sidebar Immersion ─────────────────────────────────────── */
|
||||
/* The desktop sidebar is physically 260px wide in EVERY state. Entering a
|
||||
detail page snaps the real layout — main's margin, the impersonation
|
||||
banner, `--app-sidebar-offset` — from 260px to 64px in one pass, and the
|
||||
sidebar and main content are held at their old visual positions for one
|
||||
frame, then travel together to the 64px rail. The item route commits its
|
||||
lightweight shell first; metadata and artwork stay gated until this
|
||||
compositor motion has completed.
|
||||
Nothing here touches width, margin, or any other layout property, so the
|
||||
document is laid out once per navigation instead of once per frame. Holding
|
||||
the surface at a constant size also means its 40px backdrop blur is
|
||||
rasterized against unchanging geometry rather than re-blurred each frame. */
|
||||
/* Two transforms that cancel out. `.sidebar-surface` is a 260px frame with
|
||||
`overflow: hidden` that slides 196px left, so its right edge — and the
|
||||
border drawn on it — sweeps from x=260 down to x=64. `.sidebar-inner`
|
||||
slides the same distance the other way, so its content stays pinned to the
|
||||
viewport and is progressively cut off by the frame's edge.
|
||||
|
||||
Why not animate the clip directly: Chrome runs `clip-path` transitions on
|
||||
the main thread. Entering a detail page blocks that thread for longer than
|
||||
the animation lasts (267ms and 209ms frozen frames measured on a real
|
||||
navigation), which swallowed the collapse whole. `transform` is composited
|
||||
and keeps advancing through exactly the same block. */
|
||||
.sidebar-surface {
|
||||
overflow: hidden;
|
||||
transform: translateX(0);
|
||||
transition: transform var(--duration-sidebar-collapse) var(--ease-sidebar-collapse);
|
||||
}
|
||||
.main-transition {
|
||||
transition: margin-left var(--duration-normal) var(--ease-default);
|
||||
.sidebar-surface[data-collapsed="true"] {
|
||||
transform: translateX(-196px);
|
||||
}
|
||||
.sidebar-inner {
|
||||
transform: translateX(0);
|
||||
transition: transform var(--duration-sidebar-collapse) var(--ease-sidebar-collapse);
|
||||
}
|
||||
.sidebar-surface[data-collapsed="true"] .sidebar-inner {
|
||||
transform: translateX(196px);
|
||||
}
|
||||
|
||||
/* Main's margin snaps directly to the destination so the document lays out
|
||||
once. Until the route is ready to animate, an inverse transform places the
|
||||
new layout at the old visual offset. Removing that compensation on the
|
||||
same state change that moves the sidebar keeps their edges locked together. */
|
||||
@media (min-width: 64rem) {
|
||||
.sidebar-main-stage {
|
||||
transform: none;
|
||||
transition: transform var(--duration-sidebar-collapse) var(--ease-sidebar-collapse);
|
||||
}
|
||||
.sidebar-main-stage[data-sidebar-target-collapsed="true"]:not(
|
||||
[data-sidebar-visual-collapsed="true"]
|
||||
) {
|
||||
transform: translateX(196px);
|
||||
transition: none;
|
||||
}
|
||||
.sidebar-main-stage:not(
|
||||
[data-sidebar-target-collapsed="true"]
|
||||
)[data-sidebar-visual-collapsed="true"] {
|
||||
transform: translateX(-196px);
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Sidebar contents keep their expanded layout in both states — only opacity
|
||||
and transform change, so the nav never reflows mid-animation. Labels that
|
||||
used to animate `max-width` now just fade; the frame does the hiding. */
|
||||
.sidebar-fade {
|
||||
transition: opacity var(--duration-sidebar-collapse) var(--ease-sidebar-collapse);
|
||||
}
|
||||
.sidebar-row-shift {
|
||||
transition: transform var(--duration-sidebar-collapse) var(--ease-sidebar-collapse);
|
||||
}
|
||||
.sidebar-surface[data-collapsed="true"] .sidebar-row-shift {
|
||||
/* Slides the library rows left by their reserved chevron slot so their
|
||||
icons line up with the rest of the rail's icon column. */
|
||||
transform: translateX(var(--sidebar-row-shift, 0px));
|
||||
}
|
||||
|
||||
/* ── Sidebar Scrollbar ────────────────────────────────────── */
|
||||
@@ -1848,8 +1927,10 @@
|
||||
--impersonation-accent-soft: oklch(0.82 0.14 78 / 0.12);
|
||||
--impersonation-accent-line: oklch(0.82 0.14 78 / 0.32);
|
||||
|
||||
/* Snaps straight to the destination so the document lays out once; the
|
||||
compensation transform below carries the edge across. Animating this
|
||||
margin instead would re-run layout on every frame. */
|
||||
margin-left: var(--app-sidebar-offset);
|
||||
transition: margin-left var(--duration-normal) var(--ease-default);
|
||||
position: sticky;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
@@ -1864,6 +1945,48 @@
|
||||
0 10px 32px -20px rgb(0 0 0 / 0.65);
|
||||
}
|
||||
|
||||
/* The banner renders above the routes, outside Layout, so it tracks the
|
||||
sidebar through root attributes rather than the props `.sidebar-main-stage`
|
||||
reads in-tree. The mechanism is identical: the margin above snaps to the
|
||||
destination, and an inverse transform holds the previous visual offset for
|
||||
one frame before releasing to zero on the same state change that moves the
|
||||
sidebar — so both edges travel together on the compositor.
|
||||
|
||||
Without this the banner jumped instantly while the sidebar took 300ms, and
|
||||
since it is `z-50` against the sidebar's `z-40`/`45` it painted over the
|
||||
logo on the way in and left a gap on the way out.
|
||||
|
||||
Breakpoint deliberately matches `--app-sidebar-offset` (1024px) rather than
|
||||
Tailwind's `lg` (64rem): `html[data-text-scale]` re-bases rem, so at large
|
||||
text those two diverge and the compensation must follow whichever query
|
||||
actually changes the margin it is compensating for. */
|
||||
/* While the banner holds its previous offset it is translated right, so its
|
||||
right edge sits briefly past the viewport. Layout already clips its own
|
||||
subtree this way; the banner is mounted above it, so the app root needs the
|
||||
same treatment or that overhang becomes a document-level horizontal scroll
|
||||
for the length of the transition. `clip` rather than `hidden` so this never
|
||||
becomes a scroll container and the banner's `position: sticky` keeps
|
||||
resolving against the viewport. */
|
||||
#root {
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.impersonation-banner {
|
||||
transition: transform var(--duration-sidebar-collapse) var(--ease-sidebar-collapse);
|
||||
}
|
||||
:root[data-sidebar-collapsed="true"]:not([data-sidebar-visual-collapsed="true"])
|
||||
.impersonation-banner {
|
||||
transform: translateX(196px);
|
||||
transition: none;
|
||||
}
|
||||
:root:not([data-sidebar-collapsed="true"])[data-sidebar-visual-collapsed="true"]
|
||||
.impersonation-banner {
|
||||
transform: translateX(-196px);
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Luminous top accent line — fades in from both edges so the
|
||||
banner reads as a deliberate strip rather than a hard bar. */
|
||||
.impersonation-banner::before {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
export function isSidebarExpanded(collapsed: boolean, hovered: boolean, profileMenuOpen: boolean) {
|
||||
return !collapsed || hovered || profileMenuOpen;
|
||||
}
|
||||
@@ -6,6 +8,56 @@ export function getProfileMenuSide(collapsed: boolean) {
|
||||
return collapsed ? "right" : "top";
|
||||
}
|
||||
|
||||
/** Visible width of the collapsed rail, in px. */
|
||||
export const SIDEBAR_RAIL_WIDTH = 64;
|
||||
|
||||
/** Physical width of the sidebar surface in every state, in px. */
|
||||
export const SIDEBAR_SURFACE_WIDTH = 260;
|
||||
|
||||
/**
|
||||
* True when the surface should be reduced to the rail.
|
||||
*
|
||||
* The `<aside>` is always {@link SIDEBAR_SURFACE_WIDTH} wide — nothing about it
|
||||
* reflows between states. This drives `data-collapsed`, which app.css turns into
|
||||
* paired transform animation, so the only things that move are compositor
|
||||
* layers over a surface whose 40px backdrop blur has fixed geometry.
|
||||
*/
|
||||
export function isSidebarRailCollapsed(collapsed: boolean, sidebarExpanded: boolean) {
|
||||
return collapsed && !sidebarExpanded;
|
||||
}
|
||||
|
||||
/**
|
||||
* How far a library row slides left once the surface is clipped to the rail, so
|
||||
* its icon lines up with the rest of the icon column.
|
||||
*
|
||||
* The slot reserved ahead of the icon is a chevron button when the library has
|
||||
* pins — 12px padding + a 14px glyph + 4px = 30px — and a 12px border-box
|
||||
* spacer otherwise (`w-3` includes its own `pl-3` under Tailwind's preflight).
|
||||
* Every other nav row starts its icon 12px into the row, so only the chevron
|
||||
* case is out of line; the spacer already matches and must not be shifted.
|
||||
*/
|
||||
export function libraryRowShift(hasPins: boolean) {
|
||||
return hasPins ? "-18px" : "0px";
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline style for the sidebar surface.
|
||||
*
|
||||
* While hover-expanded on a detail page the sidebar floats over the page as an
|
||||
* overlay (raised z-index + drop shadow) so expanding it never moves or resizes
|
||||
* the main content, which stays pinned to the collapsed 64px offset.
|
||||
*/
|
||||
export function sidebarSurfaceStyle({
|
||||
collapsed,
|
||||
sidebarExpanded,
|
||||
}: {
|
||||
collapsed: boolean;
|
||||
sidebarExpanded: boolean;
|
||||
}): CSSProperties | undefined {
|
||||
if (!collapsed || !sidebarExpanded) return undefined;
|
||||
return { zIndex: 45, boxShadow: "0 25px 50px -12px rgb(0 0 0 / 0.5)" };
|
||||
}
|
||||
|
||||
export interface AppNavLink {
|
||||
id: string;
|
||||
basePath: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// @vitest-environment node
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
@@ -12,6 +12,9 @@ import {
|
||||
getProfileMenuSide,
|
||||
groupAppNavLinks,
|
||||
isSidebarExpanded,
|
||||
isSidebarRailCollapsed,
|
||||
libraryRowShift,
|
||||
sidebarSurfaceStyle,
|
||||
type AppNavLink,
|
||||
} from "./AppSidebar.logic";
|
||||
|
||||
@@ -148,6 +151,10 @@ function renderSidebar(entry: string, { collapsed = false }: { collapsed?: boole
|
||||
);
|
||||
}
|
||||
|
||||
function parseMarkup(markup: string): Document {
|
||||
return new DOMParser().parseFromString(markup, "text/html");
|
||||
}
|
||||
|
||||
describe("AppSidebar", () => {
|
||||
beforeEach(() => {
|
||||
mockLogout.mockReset();
|
||||
@@ -171,12 +178,45 @@ describe("AppSidebar", () => {
|
||||
expect(markup).not.toContain("▶");
|
||||
});
|
||||
|
||||
it("uses the icon-only mark when the sidebar is collapsed", () => {
|
||||
const markup = renderSidebar("/", { collapsed: true });
|
||||
it("cross-fades the wordmark and the mark instead of swapping them", () => {
|
||||
// Both are always rendered and only their opacity differs; swapping the
|
||||
// variant outright popped at the start of the paired transform transition.
|
||||
const collapsed = renderSidebar("/", { collapsed: true });
|
||||
const expanded = renderSidebar("/");
|
||||
|
||||
expect(markup).toContain('src="/silo-icon-1024.png"');
|
||||
expect(markup).not.toContain('src="/silo-wordmark-sidebar.png"');
|
||||
expect(markup).toContain("sidebar-logo flex items-center py-6 justify-center px-2");
|
||||
for (const markup of [collapsed, expanded]) {
|
||||
expect(markup).toContain('src="/silo-icon-1024.png"');
|
||||
expect(markup).toContain('src="/silo-wordmark-sidebar.png"');
|
||||
}
|
||||
// Collapsed: the mark is the one showing, and the wordmark is out of the
|
||||
// accessibility tree so the two images never both name the sidebar.
|
||||
const collapsedDocument = parseMarkup(collapsed);
|
||||
const expandedDocument = parseMarkup(expanded);
|
||||
const collapsedWordmark = collapsedDocument
|
||||
.querySelector('img[src="/silo-wordmark-sidebar.png"]')
|
||||
?.closest(".sidebar-fade");
|
||||
const collapsedMark = collapsedDocument
|
||||
.querySelector('img[src="/silo-icon-1024.png"]')
|
||||
?.closest(".sidebar-fade");
|
||||
const expandedWordmark = expandedDocument
|
||||
.querySelector('img[src="/silo-wordmark-sidebar.png"]')
|
||||
?.closest(".sidebar-fade");
|
||||
const expandedMark = expandedDocument
|
||||
.querySelector('img[src="/silo-icon-1024.png"]')
|
||||
?.closest(".sidebar-fade");
|
||||
|
||||
expect(collapsedWordmark?.getAttribute("aria-hidden")).toBe("true");
|
||||
expect(collapsedWordmark?.classList.contains("left-5")).toBe(true);
|
||||
expect(collapsedWordmark?.classList.contains("opacity-0")).toBe(true);
|
||||
expect(collapsedMark?.getAttribute("aria-hidden")).toBe("false");
|
||||
expect(collapsedMark?.classList.contains("left-3.5")).toBe(true);
|
||||
expect(collapsedMark?.classList.contains("opacity-100")).toBe(true);
|
||||
expect(expandedWordmark?.getAttribute("aria-hidden")).toBe("false");
|
||||
expect(expandedWordmark?.classList.contains("left-5")).toBe(true);
|
||||
expect(expandedWordmark?.classList.contains("opacity-100")).toBe(true);
|
||||
expect(expandedMark?.getAttribute("aria-hidden")).toBe("true");
|
||||
expect(expandedMark?.classList.contains("left-3.5")).toBe(true);
|
||||
expect(expandedMark?.classList.contains("opacity-0")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the cinema highlight text color for active pinned catalog destinations", () => {
|
||||
@@ -214,10 +254,16 @@ describe("AppSidebar", () => {
|
||||
expect(getProfileMenuSide(true)).toBe("right");
|
||||
});
|
||||
|
||||
it("centers the profile trigger when the sidebar is collapsed", () => {
|
||||
const markup = renderSidebar("/item/42", { collapsed: true });
|
||||
it("keeps the profile trigger left-anchored and the same size in both states", () => {
|
||||
// `mx-auto` would centre the avatar in the 260px surface — far outside the
|
||||
// 64px rail — and any width swap would pop when the surface starts moving.
|
||||
const collapsed = renderSidebar("/item/42", { collapsed: true });
|
||||
const expanded = renderSidebar("/", { collapsed: false });
|
||||
|
||||
expect(markup).toContain("mx-auto h-10 w-10 justify-center px-0");
|
||||
for (const markup of [collapsed, expanded]) {
|
||||
expect(markup).toContain("flex w-full items-center gap-2.5 rounded-xl px-3 py-3");
|
||||
expect(markup).not.toContain("mx-auto h-10 w-10 justify-center px-0");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a flat Apps list when fewer than 2 distinct categories exist", () => {
|
||||
@@ -277,6 +323,96 @@ describe("AppSidebar", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("sidebar collapse surface", () => {
|
||||
it("keeps the surface 260px wide on a detail route and everywhere else", () => {
|
||||
// Nothing about the sidebar's box changes between states — the frame just
|
||||
// slides, so there is never a width to interpolate.
|
||||
for (const markup of [renderSidebar("/"), renderSidebar("/item/42", { collapsed: true })]) {
|
||||
const aside = markup.match(/<aside[^>]*class="([^"]*)"/)?.[1] ?? "";
|
||||
expect(aside).toContain("w-[260px]");
|
||||
expect(aside).toContain("overflow-hidden");
|
||||
expect(aside).not.toContain("w-16");
|
||||
}
|
||||
});
|
||||
|
||||
it("puts the blur on the counter-translated layer, not the sliding frame", () => {
|
||||
// The inner layer is screen-static, so its 40px backdrop is sampled from a
|
||||
// region that never moves; on the frame it would be re-blurred per frame.
|
||||
const markup = renderSidebar("/item/42", { collapsed: true });
|
||||
const aside = markup.match(/<aside[^>]*class="([^"]*)"/)?.[1] ?? "";
|
||||
|
||||
expect(aside).not.toContain("backdrop-blur");
|
||||
expect(markup).toContain("bg-sidebar/88 sidebar-inner");
|
||||
expect(markup).toContain("backdrop-blur-2xl");
|
||||
});
|
||||
|
||||
it("keeps the rail border on the frame so it rides along to x=64", () => {
|
||||
const aside =
|
||||
renderSidebar("/item/42", { collapsed: true }).match(/<aside[^>]*class="([^"]*)"/)?.[1] ?? "";
|
||||
expect(aside).toContain("border-sidebar-border/70");
|
||||
expect(aside).toContain("border-r");
|
||||
});
|
||||
|
||||
it("marks the surface collapsed only on a detail route", () => {
|
||||
expect(renderSidebar("/item/42", { collapsed: true })).toContain('data-collapsed="true"');
|
||||
expect(renderSidebar("/")).not.toContain("data-collapsed");
|
||||
});
|
||||
|
||||
it("keeps labels at their full layout box so the nav never reflows", () => {
|
||||
const collapsed = renderSidebar("/item/42", { collapsed: true });
|
||||
|
||||
expect(collapsed).toContain("sidebar-fade max-w-[180px] truncate opacity-0");
|
||||
// The old max-width animation relaid out the whole nav subtree per frame.
|
||||
expect(collapsed).not.toContain("max-w-0");
|
||||
expect(collapsed).not.toContain("transition-[opacity,max-width]");
|
||||
});
|
||||
|
||||
it("reserves the library chevron slot in both states and shifts the row instead", () => {
|
||||
const collapsed = renderSidebar("/item/42", { collapsed: true });
|
||||
const expanded = renderSidebar("/");
|
||||
|
||||
for (const markup of [collapsed, expanded]) {
|
||||
expect(markup).toContain("sidebar-row-shift flex flex-1 items-center");
|
||||
// Mocked library 7 has a pin, so its slot is the wider chevron button.
|
||||
expect(markup).toContain("--sidebar-row-shift:-18px");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("libraryRowShift", () => {
|
||||
it("only shifts rows whose chevron slot pushes the icon out of the column", () => {
|
||||
// A pinned library reserves a 30px chevron button; every other nav row
|
||||
// starts its icon 12px in, so the row has to come back 18px.
|
||||
expect(libraryRowShift(true)).toBe("-18px");
|
||||
// The unpinned spacer is `w-3` with `pl-3` — border-box, so 12px total.
|
||||
// It already lines up, and shifting it would overshoot to the left.
|
||||
expect(libraryRowShift(false)).toBe("0px");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSidebarRailCollapsed", () => {
|
||||
it("clips to the rail only when the route collapsed it and nothing holds it open", () => {
|
||||
expect(isSidebarRailCollapsed(true, false)).toBe(true);
|
||||
expect(isSidebarRailCollapsed(false, true)).toBe(false);
|
||||
// Hover-to-expand and the profile menu both re-open the clip.
|
||||
expect(isSidebarRailCollapsed(true, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sidebarSurfaceStyle", () => {
|
||||
it("floats hover expansion above the page instead of resizing it", () => {
|
||||
const style = sidebarSurfaceStyle({ collapsed: true, sidebarExpanded: true });
|
||||
|
||||
expect(style?.zIndex).toBe(45);
|
||||
expect(style?.boxShadow).toContain("0 25px 50px -12px");
|
||||
});
|
||||
|
||||
it("does not raise or shadow the surface in either resting state", () => {
|
||||
expect(sidebarSurfaceStyle({ collapsed: true, sidebarExpanded: false })).toBeUndefined();
|
||||
expect(sidebarSurfaceStyle({ collapsed: false, sidebarExpanded: true })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupAppNavLinks", () => {
|
||||
const link = (id: string, category?: string): AppNavLink => ({
|
||||
id,
|
||||
|
||||
+584
-530
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ import { VisuallyHidden } from "radix-ui";
|
||||
import { useViewTransitionNavigate } from "@/hooks/useViewTransition";
|
||||
import { useDebounce } from "@/hooks/useDebounce";
|
||||
import { buildQueryCatalogHref } from "@/pages/catalogSearchParams";
|
||||
import { useSidebarItemNavigation } from "@/components/sidebarItemNavigationContext";
|
||||
import { createEmptyQueryDefinition, type BrowseItem } from "@/api/types";
|
||||
import { createCatalogSearchState, fetchCatalogPage } from "@/hooks/queries/catalog";
|
||||
import { useSearchMediaScope } from "@/hooks/useSearchMediaScope";
|
||||
@@ -111,6 +112,7 @@ export function GlobalSearch({
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const navigate = useViewTransitionNavigate();
|
||||
const beginSidebarItemNavigation = useSidebarItemNavigation();
|
||||
const debouncedQuery = useDebounce(query.trim(), DEBOUNCE_MS);
|
||||
const tmdbDebouncedQuery = useDebounce(query.trim(), TMDB_DEBOUNCE_MS);
|
||||
const canRequest = useCanRequest();
|
||||
@@ -191,11 +193,12 @@ export function GlobalSearch({
|
||||
|
||||
const handlePickItem = useCallback(
|
||||
(contentId: string) => {
|
||||
navigate(`/item/${encodeURIComponent(contentId)}`);
|
||||
const href = `/item/${encodeURIComponent(contentId)}`;
|
||||
if (!beginSidebarItemNavigation?.({ href })) navigate(href);
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
},
|
||||
[navigate],
|
||||
[beginSidebarItemNavigation, navigate],
|
||||
);
|
||||
|
||||
// Reset selectedIndex when query changes
|
||||
|
||||
@@ -8,6 +8,7 @@ import { cn } from "@/lib/utils";
|
||||
import type { SectionItem } from "@/api/types";
|
||||
import { buildItemHref, buildMediaPlayHref } from "@/lib/mediaNavigation";
|
||||
import { useAudiobookPlaybackController } from "@/pages/audiobooks/player/audiobookPlaybackContext";
|
||||
import ViewTransitionLink from "@/components/ViewTransitionLink";
|
||||
|
||||
interface HeroBannerProps {
|
||||
items: SectionItem[];
|
||||
@@ -264,13 +265,13 @@ export default function HeroBanner({
|
||||
)}
|
||||
{playLabel}
|
||||
</Link>
|
||||
<Link
|
||||
<ViewTransitionLink
|
||||
to={buildItemHref({ contentId: current.content_id, libraryId })}
|
||||
className="pill pill-glass transition-colors duration-[--duration-fast]"
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
More Info
|
||||
</Link>
|
||||
</ViewTransitionLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { catalogKeys } from "@/hooks/queries/keys";
|
||||
import { SIDEBAR_DETAILS_REVEAL_DEADLINE_MS } from "./sidebarItemNavigation";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
location: { pathname: "/", search: "", key: "home" },
|
||||
navigate: vi.fn(),
|
||||
prefetchQuery: vi.fn(),
|
||||
renderSurface: true,
|
||||
beginResult: undefined as boolean | undefined,
|
||||
}));
|
||||
|
||||
vi.mock("react-router", async () => {
|
||||
const actual = await vi.importActual<typeof import("react-router")>("react-router");
|
||||
return {
|
||||
...actual,
|
||||
useLocation: () => mocks.location,
|
||||
useNavigate: () => mocks.navigate,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@tanstack/react-query", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@tanstack/react-query")>("@tanstack/react-query");
|
||||
return { ...actual, useQueryClient: () => ({ prefetchQuery: mocks.prefetchQuery }) };
|
||||
});
|
||||
|
||||
vi.mock("@/hooks/useAuth", () => ({ useAuth: () => ({ user: { username: "Admin" } }) }));
|
||||
vi.mock("@/hooks/useCurrentProfile", () => ({
|
||||
useCurrentProfile: () => ({ profile: { name: "Admin" } }),
|
||||
}));
|
||||
vi.mock("@/hooks/useIsActingAdmin", () => ({ useIsActingAdmin: () => false }));
|
||||
vi.mock("@/playback/watchPlaybackContext", () => ({
|
||||
useWatchPlaybackController: () => ({ isBackgroundBarVisible: false }),
|
||||
}));
|
||||
vi.mock("@/pages/audiobooks/player/audiobookPlaybackContext", () => ({
|
||||
useAudiobookPlaybackController: () => null,
|
||||
}));
|
||||
vi.mock("@/hooks/queries/catalogRead", () => ({ fetchCatalogItemDetail: vi.fn() }));
|
||||
vi.mock("@/components/GlobalSearch", () => ({ GlobalSearch: () => null }));
|
||||
vi.mock("@/components/ServerActivity", () => ({ default: () => null }));
|
||||
vi.mock("@/components/SiloBrand", () => ({ SiloBrand: () => <span>Silo</span> }));
|
||||
vi.mock("@/components/ViewTransitionLink", () => ({
|
||||
default: ({ children }: { children: ReactNode }) => <a href="/">{children}</a>,
|
||||
}));
|
||||
vi.mock("@/components/ui/sheet", () => ({
|
||||
Sheet: () => null,
|
||||
SheetContent: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
SheetHeader: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
SheetTitle: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
vi.mock("@/components/AppSidebar", () => ({
|
||||
default: ({ collapsed }: { collapsed?: boolean }) =>
|
||||
mocks.renderSurface ? (
|
||||
<aside
|
||||
className="sidebar-surface"
|
||||
data-collapsed={collapsed ? "true" : undefined}
|
||||
data-testid="sidebar-surface"
|
||||
>
|
||||
<div className="sidebar-inner" data-testid="sidebar-inner" />
|
||||
<div className="sidebar-row-shift" data-testid="sidebar-row" />
|
||||
</aside>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
import Layout from "./Layout";
|
||||
import {
|
||||
useSidebarItemDetailsReady,
|
||||
useSidebarItemNavigation,
|
||||
} from "./sidebarItemNavigationContext";
|
||||
|
||||
function Harness() {
|
||||
const begin = useSidebarItemNavigation();
|
||||
const ready = useSidebarItemDetailsReady();
|
||||
return (
|
||||
<>
|
||||
<output aria-label="details-ready">{String(ready)}</output>
|
||||
<button
|
||||
onClick={() => {
|
||||
mocks.beginResult = begin?.({
|
||||
href: "/item/movie-1?libraryId=2",
|
||||
replace: true,
|
||||
state: { source: "home" },
|
||||
});
|
||||
}}
|
||||
>
|
||||
begin item
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
mocks.beginResult = begin?.({ href: "/library/1" });
|
||||
}}
|
||||
>
|
||||
begin library
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderLayout() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<Layout>
|
||||
<Harness />
|
||||
</Layout>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
function setRoute(pathname: string, key: string) {
|
||||
mocks.location = { pathname, search: "", key };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
mocks.location = { pathname: "/", search: "", key: "home" };
|
||||
mocks.navigate.mockReset();
|
||||
mocks.prefetchQuery.mockReset();
|
||||
mocks.renderSurface = true;
|
||||
mocks.beginResult = undefined;
|
||||
vi.stubGlobal("matchMedia", (query: string) => ({
|
||||
matches: query === "(min-width: 64rem)",
|
||||
media: query,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
}));
|
||||
vi.stubGlobal(
|
||||
"requestAnimationFrame",
|
||||
vi.fn(() => 1),
|
||||
);
|
||||
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Layout item navigation", () => {
|
||||
it("declines interception on an item route, for a non-item href, and below lg", () => {
|
||||
const view = renderLayout();
|
||||
const begin = screen.getByRole("button", { name: "begin item" });
|
||||
|
||||
setRoute("/item/already-there", "item");
|
||||
view.rerender(
|
||||
<MemoryRouter>
|
||||
<Layout>
|
||||
<Harness />
|
||||
</Layout>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
fireEvent.click(begin);
|
||||
expect(mocks.beginResult).toBe(false);
|
||||
expect(mocks.navigate).not.toHaveBeenCalled();
|
||||
|
||||
setRoute("/", "home-2");
|
||||
view.rerender(
|
||||
<MemoryRouter>
|
||||
<Layout>
|
||||
<Harness />
|
||||
</Layout>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "begin library" }));
|
||||
expect(mocks.beginResult).toBe(false);
|
||||
expect(mocks.navigate).not.toHaveBeenCalled();
|
||||
|
||||
const contextBegin = screen.getByRole("button", { name: "begin item" });
|
||||
fireEvent.click(contextBegin);
|
||||
expect(mocks.beginResult).toBe(true);
|
||||
expect(mocks.navigate).toHaveBeenCalledOnce();
|
||||
|
||||
mocks.navigate.mockReset();
|
||||
vi.stubGlobal("matchMedia", () => ({
|
||||
matches: false,
|
||||
media: "(min-width: 64rem)",
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
}));
|
||||
view.rerender(
|
||||
<MemoryRouter>
|
||||
<Layout>
|
||||
<Harness />
|
||||
</Layout>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "begin item" }));
|
||||
expect(mocks.beginResult).toBe(false);
|
||||
expect(mocks.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prefetches and forwards navigation options on a successful intercept", () => {
|
||||
renderLayout();
|
||||
fireEvent.click(screen.getByRole("button", { name: "begin item" }));
|
||||
|
||||
expect(mocks.prefetchQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ queryKey: catalogKeys.itemDetail("movie-1", 2) }),
|
||||
);
|
||||
expect(mocks.navigate).toHaveBeenCalledWith("/item/movie-1?libraryId=2", {
|
||||
replace: true,
|
||||
state: { source: "home" },
|
||||
viewTransition: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Layout detail reveal", () => {
|
||||
it("reveals immediately when the sidebar surface is absent", () => {
|
||||
mocks.renderSurface = false;
|
||||
const view = renderLayout();
|
||||
setRoute("/item/movie-1", "item");
|
||||
|
||||
act(() =>
|
||||
view.rerender(
|
||||
<MemoryRouter>
|
||||
<Layout>
|
||||
<Harness />
|
||||
</Layout>
|
||||
</MemoryRouter>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(screen.getByRole("status", { name: "details-ready" })).toHaveTextContent("true");
|
||||
});
|
||||
|
||||
it("reveals as soon as the surface settles", () => {
|
||||
const view = renderLayout();
|
||||
setRoute("/item/movie-1", "item");
|
||||
act(() =>
|
||||
view.rerender(
|
||||
<MemoryRouter>
|
||||
<Layout>
|
||||
<Harness />
|
||||
</Layout>
|
||||
</MemoryRouter>,
|
||||
),
|
||||
);
|
||||
expect(screen.getByRole("status", { name: "details-ready" })).toHaveTextContent("false");
|
||||
|
||||
screen.getByTestId("sidebar-surface").setAttribute("data-collapsed", "true");
|
||||
act(() => vi.advanceTimersByTime(50));
|
||||
|
||||
expect(screen.getByRole("status", { name: "details-ready" })).toHaveTextContent("true");
|
||||
});
|
||||
|
||||
it("reveals by the deadline while hover expansion holds the surface open", () => {
|
||||
const view = renderLayout();
|
||||
setRoute("/item/movie-1", "item");
|
||||
act(() =>
|
||||
view.rerender(
|
||||
<MemoryRouter>
|
||||
<Layout>
|
||||
<Harness />
|
||||
</Layout>
|
||||
</MemoryRouter>,
|
||||
),
|
||||
);
|
||||
|
||||
act(() => vi.advanceTimersByTime(SIDEBAR_DETAILS_REVEAL_DEADLINE_MS));
|
||||
|
||||
expect(screen.getByRole("status", { name: "details-ready" })).toHaveTextContent("true");
|
||||
});
|
||||
|
||||
it("reveals by the deadline when requestAnimationFrame never fires", () => {
|
||||
const view = renderLayout();
|
||||
setRoute("/item/movie-1", "item");
|
||||
act(() =>
|
||||
view.rerender(
|
||||
<MemoryRouter>
|
||||
<Layout>
|
||||
<Harness />
|
||||
</Layout>
|
||||
</MemoryRouter>,
|
||||
),
|
||||
);
|
||||
|
||||
act(() => vi.advanceTimersByTime(SIDEBAR_DETAILS_REVEAL_DEADLINE_MS));
|
||||
|
||||
expect(screen.getByRole("status", { name: "details-ready" })).toHaveTextContent("true");
|
||||
});
|
||||
|
||||
it("cancels reveal polling on unmount", () => {
|
||||
const view = renderLayout();
|
||||
setRoute("/item/movie-1", "item");
|
||||
act(() =>
|
||||
view.rerender(
|
||||
<MemoryRouter>
|
||||
<Layout>
|
||||
<Harness />
|
||||
</Layout>
|
||||
</MemoryRouter>,
|
||||
),
|
||||
);
|
||||
expect(vi.getTimerCount()).toBeGreaterThan(0);
|
||||
|
||||
view.unmount();
|
||||
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("reveals only for a collapsed surface transform transition", () => {
|
||||
const view = renderLayout();
|
||||
setRoute("/item/movie-1", "item");
|
||||
act(() =>
|
||||
view.rerender(
|
||||
<MemoryRouter>
|
||||
<Layout>
|
||||
<Harness />
|
||||
</Layout>
|
||||
</MemoryRouter>,
|
||||
),
|
||||
);
|
||||
const surface = screen.getByTestId("sidebar-surface");
|
||||
surface.setAttribute("data-collapsed", "true");
|
||||
|
||||
fireEvent.transitionEnd(screen.getByTestId("sidebar-inner"), { propertyName: "transform" });
|
||||
fireEvent.transitionEnd(screen.getByTestId("sidebar-row"), { propertyName: "transform" });
|
||||
fireEvent.transitionEnd(surface, { propertyName: "opacity" });
|
||||
expect(screen.getByRole("status", { name: "details-ready" })).toHaveTextContent("false");
|
||||
|
||||
fireEvent.transitionEnd(surface, { propertyName: "transform" });
|
||||
expect(screen.getByRole("status", { name: "details-ready" })).toHaveTextContent("true");
|
||||
});
|
||||
});
|
||||
+199
-88
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useLocation } from "react-router";
|
||||
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
|
||||
import { Link, useLocation, useNavigate } from "react-router";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Menu, Search } from "lucide-react";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
@@ -11,9 +12,24 @@ import { SiloBrand } from "@/components/SiloBrand";
|
||||
import { GlobalSearch } from "@/components/GlobalSearch";
|
||||
import ViewTransitionLink from "@/components/ViewTransitionLink";
|
||||
import { buildQueryCatalogHref, parseCatalogSearchParams } from "@/pages/catalogSearchParams";
|
||||
import type { ReactNode } from "react";
|
||||
import type { ReactNode, TransitionEvent as ReactTransitionEvent } from "react";
|
||||
import { useWatchPlaybackController } from "@/playback/watchPlaybackContext";
|
||||
import { useAudiobookPlaybackController } from "@/pages/audiobooks/player/audiobookPlaybackContext";
|
||||
import {
|
||||
prefersReducedMotion,
|
||||
useImmediateSidebarCollapse,
|
||||
} from "@/hooks/useImmediateSidebarCollapse";
|
||||
import SidebarItemNavigationProvider from "@/components/SidebarItemNavigationProvider";
|
||||
import {
|
||||
hasRunningSidebarTransition,
|
||||
isCollapsedSidebarSurface,
|
||||
parseItemNavigationHref,
|
||||
SIDEBAR_DETAILS_REVEAL_DEADLINE_MS,
|
||||
type SidebarItemNavigationRequest,
|
||||
} from "@/components/sidebarItemNavigation";
|
||||
import { useSidebarItemDetailsGate } from "@/hooks/useSidebarItemDetailsGate";
|
||||
import { catalogKeys } from "@/hooks/queries/keys";
|
||||
import { fetchCatalogItemDetail } from "@/hooks/queries/catalogRead";
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode;
|
||||
@@ -21,6 +37,8 @@ interface LayoutProps {
|
||||
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
// Tracks whether the mobile header should slide off-screen on scroll.
|
||||
// We auto-hide on Calendar to maximize vertical room for the sticky week strip
|
||||
@@ -36,6 +54,9 @@ export default function Layout({ children }: LayoutProps) {
|
||||
const isHomePath = location.pathname === "/";
|
||||
const isLibraryRoute = location.pathname.startsWith("/library/");
|
||||
const isItemRoute = location.pathname.startsWith("/item/");
|
||||
// Breakpoint changes naturally cause other layout renders; navigation only
|
||||
// needs the viewport value at the moment it is attempted.
|
||||
const hasDesktopSidebar = window.matchMedia("(min-width: 64rem)").matches;
|
||||
const isSearchLandingRoute =
|
||||
location.pathname === "/catalog" &&
|
||||
(() => {
|
||||
@@ -54,23 +75,107 @@ export default function Layout({ children }: LayoutProps) {
|
||||
isRecommendationsRoute ||
|
||||
isCalendarRoute;
|
||||
|
||||
// Immersion: auto-collapse sidebar on detail pages so content gets full stage
|
||||
// The item route commits its lightweight shell immediately. The following
|
||||
// frame starts the sidebar/main compositor transition; prefetched details
|
||||
// and artwork are revealed only after that motion completes.
|
||||
const isDetailImmersion = isItemRoute;
|
||||
const targetDetailImmersion = isDetailImmersion;
|
||||
const visualDetailImmersion = useImmediateSidebarCollapse(targetDetailImmersion);
|
||||
const {
|
||||
itemDetailsReady,
|
||||
pendingLocationKey,
|
||||
reveal: revealItemDetails,
|
||||
} = useSidebarItemDetailsGate(location.key, isItemRoute && hasDesktopSidebar);
|
||||
|
||||
// Publish the current sidebar width on the document root so out-of-tree
|
||||
// chrome (notably ImpersonationBanner, which renders above all routes)
|
||||
// can align with whichever sidebar state is active.
|
||||
const beginItemNavigation = useCallback(
|
||||
(request: SidebarItemNavigationRequest) => {
|
||||
const itemTarget = parseItemNavigationHref(request.href, window.location.origin);
|
||||
if (isItemRoute || !itemTarget || !hasDesktopSidebar) {
|
||||
return false;
|
||||
}
|
||||
|
||||
void queryClient.prefetchQuery({
|
||||
queryKey: catalogKeys.itemDetail(itemTarget.contentId, itemTarget.libraryId),
|
||||
queryFn: () => fetchCatalogItemDetail(itemTarget.contentId, itemTarget.libraryId),
|
||||
});
|
||||
navigate(request.href, {
|
||||
replace: request.replace,
|
||||
state: request.state,
|
||||
viewTransition: true,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
[hasDesktopSidebar, isItemRoute, navigate, queryClient],
|
||||
);
|
||||
|
||||
// Transitionend is the fast path. Polling handles reconstructed layers, and
|
||||
// the hard deadline guarantees hover expansion or a hidden-tab suspended rAF
|
||||
// can never leave the item route on its lightweight shell indefinitely.
|
||||
useEffect(() => {
|
||||
if (!isItemRoute || !pendingLocationKey) return;
|
||||
const startedAt = Date.now();
|
||||
let timer: number;
|
||||
let cancelled = false;
|
||||
|
||||
const revealWhenSettled = () => {
|
||||
if (cancelled) return;
|
||||
const surface = document.querySelector<HTMLElement>(".sidebar-surface");
|
||||
const deadlineReached =
|
||||
prefersReducedMotion() || Date.now() - startedAt >= SIDEBAR_DETAILS_REVEAL_DEADLINE_MS;
|
||||
if (
|
||||
!deadlineReached &&
|
||||
surface &&
|
||||
(!isCollapsedSidebarSurface(surface) || hasRunningSidebarTransition(surface))
|
||||
) {
|
||||
const remaining = SIDEBAR_DETAILS_REVEAL_DEADLINE_MS - (Date.now() - startedAt);
|
||||
timer = window.setTimeout(revealWhenSettled, Math.min(50, Math.max(0, remaining)));
|
||||
return;
|
||||
}
|
||||
revealItemDetails(pendingLocationKey);
|
||||
};
|
||||
|
||||
revealWhenSettled();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [isItemRoute, pendingLocationKey, revealItemDetails]);
|
||||
|
||||
const handleSidebarTransitionEnd = useCallback(
|
||||
(event: ReactTransitionEvent<HTMLDivElement>) => {
|
||||
if (event.propertyName === "transform" && isCollapsedSidebarSurface(event.target)) {
|
||||
if (pendingLocationKey) revealItemDetails(pendingLocationKey);
|
||||
}
|
||||
},
|
||||
[pendingLocationKey, revealItemDetails],
|
||||
);
|
||||
|
||||
// Publish both sidebar states on the document root so out-of-tree chrome
|
||||
// (notably ImpersonationBanner, which renders above all routes) can align
|
||||
// with the sidebar. The target drives the snapped `--app-sidebar-offset`;
|
||||
// the visual state is the same one-frame handoff `.sidebar-main-stage` uses
|
||||
// in-tree, letting that chrome animate its edge instead of jumping.
|
||||
//
|
||||
// Layout effect, not effect: the sidebar receives `data-collapsed` during
|
||||
// render, so publishing these after paint would start the banner's transition
|
||||
// a frame late and it would trail the sidebar by ~23px at peak velocity.
|
||||
useLayoutEffect(() => {
|
||||
const root = document.documentElement;
|
||||
if (isDetailImmersion) {
|
||||
if (targetDetailImmersion) {
|
||||
root.dataset.sidebarCollapsed = "true";
|
||||
} else {
|
||||
delete root.dataset.sidebarCollapsed;
|
||||
}
|
||||
if (visualDetailImmersion) {
|
||||
root.dataset.sidebarVisualCollapsed = "true";
|
||||
} else {
|
||||
delete root.dataset.sidebarVisualCollapsed;
|
||||
}
|
||||
return () => {
|
||||
delete root.dataset.sidebarCollapsed;
|
||||
delete root.dataset.sidebarVisualCollapsed;
|
||||
};
|
||||
}, [isDetailImmersion]);
|
||||
}, [targetDetailImmersion, visualDetailImmersion]);
|
||||
|
||||
// Auto-hide the mobile header on scroll-down for the Calendar route only.
|
||||
// Direction-based (not threshold-based) so a small scroll-up reveals the
|
||||
@@ -116,91 +221,97 @@ export default function Layout({ children }: LayoutProps) {
|
||||
}, [mobileHeaderHidden]);
|
||||
|
||||
return (
|
||||
<div className="bg-background relative min-h-[100dvh] overflow-x-clip">
|
||||
<a
|
||||
href="#main-content"
|
||||
className="focus:bg-background focus:text-foreground focus:ring-ring sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-50 focus:rounded-lg focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:ring-2 focus:outline-none"
|
||||
>
|
||||
Skip to content
|
||||
</a>
|
||||
<GlobalSearch />
|
||||
<div className="from-primary/8 pointer-events-none fixed inset-x-0 top-0 z-0 h-40 bg-gradient-to-b to-transparent blur-3xl" />
|
||||
{/* Desktop sidebar — hidden below lg */}
|
||||
<div className="hidden lg:block">
|
||||
<AppSidebar collapsed={isDetailImmersion} />
|
||||
</div>
|
||||
<SidebarItemNavigationProvider begin={beginItemNavigation} itemDetailsReady={itemDetailsReady}>
|
||||
<div className="bg-background relative min-h-[100dvh] overflow-x-clip">
|
||||
<a
|
||||
href="#main-content"
|
||||
className="focus:bg-background focus:text-foreground focus:ring-ring sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-50 focus:rounded-lg focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:ring-2 focus:outline-none"
|
||||
>
|
||||
Skip to content
|
||||
</a>
|
||||
<GlobalSearch />
|
||||
<div className="from-primary/8 pointer-events-none fixed inset-x-0 top-0 z-0 h-40 bg-gradient-to-b to-transparent blur-3xl" />
|
||||
{/* Desktop sidebar — hidden below lg. Its box stays 260px wide in both
|
||||
states; `collapsed` only drives the moving frame that hides everything
|
||||
past the 64px rail, so entering a detail page never resizes it. */}
|
||||
<div className="hidden lg:block" onTransitionEnd={handleSidebarTransitionEnd}>
|
||||
<AppSidebar collapsed={visualDetailImmersion} />
|
||||
</div>
|
||||
|
||||
{/* Mobile header — visible below lg. Slides up on scroll-down within
|
||||
{/* Mobile header — visible below lg. Slides up on scroll-down within
|
||||
the Calendar route to free vertical space; pulling up reveals it. */}
|
||||
<div
|
||||
className={`mobile-header glass-dark border-border/70 sticky top-0 z-30 mx-3 mt-3 flex items-center justify-between rounded-2xl border px-4 py-3 transition-transform duration-200 ease-out lg:hidden ${
|
||||
mobileHeaderHidden ? "-translate-y-[140%]" : "translate-y-0"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-accent/60 flex h-10 w-10 items-center justify-center rounded-xl transition-all active:scale-[0.98]"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
<ViewTransitionLink to="/" className="flex items-center gap-2.5">
|
||||
<SiloBrand className="h-10 w-[94px]" />
|
||||
</ViewTransitionLink>
|
||||
<div
|
||||
className={`mobile-header glass-dark border-border/70 sticky top-0 z-30 mx-3 mt-3 flex items-center justify-between rounded-2xl border px-4 py-3 transition-transform duration-200 ease-out lg:hidden ${
|
||||
mobileHeaderHidden ? "-translate-y-[140%]" : "translate-y-0"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-accent/60 flex h-10 w-10 items-center justify-center rounded-xl transition-all active:scale-[0.98]"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
<ViewTransitionLink to="/" className="flex items-center gap-2.5">
|
||||
<SiloBrand className="h-10 w-[94px]" />
|
||||
</ViewTransitionLink>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ViewTransitionLink
|
||||
to={buildQueryCatalogHref()}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-accent/60 flex h-10 w-10 items-center justify-center rounded-xl transition-all active:scale-[0.98]"
|
||||
>
|
||||
<Search className="h-5 w-5" />
|
||||
</ViewTransitionLink>
|
||||
{showAdminActivity && <ServerActivity hideWhenEmpty />}
|
||||
<Link
|
||||
to="/settings/playback"
|
||||
className="bg-primary text-primary-foreground flex h-9 w-9 items-center justify-center rounded-xl text-xs font-bold shadow-[0_16px_32px_-22px_rgba(0,0,0,0.7)]"
|
||||
>
|
||||
{profile?.name?.charAt(0).toUpperCase() ??
|
||||
user?.username?.charAt(0).toUpperCase() ??
|
||||
"?"}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ViewTransitionLink
|
||||
to={buildQueryCatalogHref()}
|
||||
className="text-muted-foreground hover:text-foreground hover:bg-accent/60 flex h-10 w-10 items-center justify-center rounded-xl transition-all active:scale-[0.98]"
|
||||
>
|
||||
<Search className="h-5 w-5" />
|
||||
</ViewTransitionLink>
|
||||
{showAdminActivity && <ServerActivity hideWhenEmpty />}
|
||||
<Link
|
||||
to="/settings/playback"
|
||||
className="bg-primary text-primary-foreground flex h-9 w-9 items-center justify-center rounded-xl text-xs font-bold shadow-[0_16px_32px_-22px_rgba(0,0,0,0.7)]"
|
||||
>
|
||||
{profile?.name?.charAt(0).toUpperCase() ??
|
||||
user?.username?.charAt(0).toUpperCase() ??
|
||||
"?"}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile sidebar drawer */}
|
||||
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||
<SheetContent side="left" className="w-[280px] p-0 sm:max-w-[280px]">
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Navigation</SheetTitle>
|
||||
</SheetHeader>
|
||||
<AppSidebar onNavigate={() => setMobileOpen(false)} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
{/* Mobile sidebar drawer */}
|
||||
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||
<SheetContent side="left" className="w-[280px] p-0 sm:max-w-[280px]">
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Navigation</SheetTitle>
|
||||
</SheetHeader>
|
||||
<AppSidebar onNavigate={() => setMobileOpen(false)} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
{/* Desktop admin activity indicator (top-right, hidden on mobile) */}
|
||||
{showAdminActivity && (
|
||||
<div className="fixed top-6 right-5 z-40 hidden lg:block">
|
||||
<ServerActivity hideWhenEmpty />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main content — offset by sidebar width on desktop */}
|
||||
<main
|
||||
id="main-content"
|
||||
className={`main-transition relative min-h-screen ${
|
||||
isDetailImmersion ? "lg:ml-16" : "lg:ml-[260px]"
|
||||
} ${hasBackgroundBar ? "pb-32 sm:pb-36" : ""}`}
|
||||
style={{ viewTransitionName: "main-content" }}
|
||||
>
|
||||
{needsNoPadding ? (
|
||||
children
|
||||
) : (
|
||||
<div className="relative z-10 px-4 py-4 sm:px-6 lg:px-10 lg:py-8 xl:px-12">
|
||||
{children}
|
||||
{/* Desktop admin activity indicator (top-right, hidden on mobile) */}
|
||||
{showAdminActivity && (
|
||||
<div className="fixed top-6 right-5 z-40 hidden lg:block">
|
||||
<ServerActivity hideWhenEmpty />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Main content — offset by sidebar width on desktop */}
|
||||
<main
|
||||
id="main-content"
|
||||
data-sidebar-target-collapsed={targetDetailImmersion ? "true" : undefined}
|
||||
data-sidebar-visual-collapsed={visualDetailImmersion ? "true" : undefined}
|
||||
className={`sidebar-main-stage relative min-h-screen ${
|
||||
targetDetailImmersion ? "lg:ml-16" : "lg:ml-[260px]"
|
||||
} ${hasBackgroundBar ? "pb-32 sm:pb-36" : ""}`}
|
||||
style={{ viewTransitionName: "main-content" }}
|
||||
>
|
||||
{needsNoPadding ? (
|
||||
children
|
||||
) : (
|
||||
<div className="relative z-10 px-4 py-4 sm:px-6 lg:px-10 lg:py-8 xl:px-12">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</SidebarItemNavigationProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ResolvedSection } from "@/api/types";
|
||||
import SidebarItemNavigationProvider from "./SidebarItemNavigationProvider";
|
||||
import NowListeningHero from "./NowListeningHero";
|
||||
|
||||
vi.mock("@/hooks/queries/catalogRead", () => ({
|
||||
useCatalogItemDetail: () => ({ data: undefined }),
|
||||
}));
|
||||
vi.mock("@/hooks/useAmbientColor", () => ({ useAmbientColor: () => undefined }));
|
||||
vi.mock("@/hooks/useOverlayPrefs", () => ({ useOverlayPrefs: () => ({ prefs: {} }) }));
|
||||
vi.mock("@/pages/audiobooks/player/audiobookPlaybackContext", () => ({
|
||||
useAudiobookPlaybackController: () => null,
|
||||
}));
|
||||
|
||||
describe("NowListeningHero", () => {
|
||||
it("routes item details through the sidebar interception path", () => {
|
||||
const begin = vi.fn(() => true);
|
||||
const section = {
|
||||
title: "Continue listening",
|
||||
items: [
|
||||
{
|
||||
content_id: "audiobook-1",
|
||||
type: "audiobook",
|
||||
title: "The Book",
|
||||
position_seconds: 120,
|
||||
duration_seconds: 3600,
|
||||
},
|
||||
],
|
||||
} as unknown as ResolvedSection;
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SidebarItemNavigationProvider begin={begin} itemDetailsReady>
|
||||
<NowListeningHero section={section} libraryId={7} />
|
||||
</SidebarItemNavigationProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("link", { name: "More Info" }));
|
||||
|
||||
expect(begin).toHaveBeenCalledWith({
|
||||
href: "/item/audiobook-1?libraryId=7",
|
||||
replace: undefined,
|
||||
state: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { Link } from "react-router";
|
||||
import { Info, Pause, Play } from "lucide-react";
|
||||
import type { ResolvedSection } from "@/api/types";
|
||||
import ContinueWatchingCard from "@/components/ContinueWatchingCard";
|
||||
import ViewTransitionLink from "@/components/ViewTransitionLink";
|
||||
import MediaCarousel from "@/components/MediaCarousel";
|
||||
import { useCatalogItemDetail } from "@/hooks/queries/catalogRead";
|
||||
import { useAmbientColor } from "@/hooks/useAmbientColor";
|
||||
@@ -150,7 +151,7 @@ export default function NowListeningHero({ section, libraryId }: NowListeningHer
|
||||
|
||||
<div className="relative z-10 px-4 pt-28 pb-10 sm:px-6 sm:pt-32 sm:pb-12 lg:px-10 xl:px-12">
|
||||
<div className="flex max-w-[1380px] flex-col gap-6 sm:flex-row sm:items-center sm:gap-10">
|
||||
<Link
|
||||
<ViewTransitionLink
|
||||
to={itemHref}
|
||||
className="block w-36 shrink-0 self-start sm:w-48 sm:self-center lg:w-56"
|
||||
>
|
||||
@@ -170,7 +171,7 @@ export default function NowListeningHero({ section, libraryId }: NowListeningHer
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</ViewTransitionLink>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="hero-eyebrow mb-3">
|
||||
@@ -216,13 +217,13 @@ export default function NowListeningHero({ section, libraryId }: NowListeningHer
|
||||
)}
|
||||
{resumeLabel}
|
||||
</Link>
|
||||
<Link
|
||||
<ViewTransitionLink
|
||||
to={itemHref}
|
||||
className="pill pill-glass transition-colors duration-[--duration-fast]"
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
More Info
|
||||
</Link>
|
||||
</ViewTransitionLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Check, Film, Library, Loader2, Plus, Tv } from "lucide-react";
|
||||
import type { MediaRequest, RequestMediaResult } from "@/api/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatRequestReason, formatRequestStatus, tmdbImageURL } from "@/lib/mediaRequests";
|
||||
import ViewTransitionLink from "@/components/ViewTransitionLink";
|
||||
|
||||
const POSTER_WIDTH = "w-[148px] sm:w-[164px] lg:w-[184px]";
|
||||
|
||||
@@ -198,14 +199,14 @@ function MineCard({ request, fluid }: { request: MediaRequest; fluid?: boolean }
|
||||
|
||||
function LibraryCardLink({ contentID, title }: { contentID: string; title: string }) {
|
||||
return (
|
||||
<Link
|
||||
<ViewTransitionLink
|
||||
to={`/item/${encodeURIComponent(contentID)}`}
|
||||
aria-label={`Open ${title} in library`}
|
||||
className="absolute top-2 left-2 inline-flex max-w-[calc(100%-1rem)] items-center gap-1.5 rounded-full bg-black/70 px-2 py-[3px] text-[10px] leading-none font-semibold tracking-[0.06em] text-white uppercase shadow-sm ring-1 shadow-white/15 backdrop-blur-md transition-colors hover:bg-black/85 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/70"
|
||||
>
|
||||
<Library className="h-3 w-3 shrink-0" strokeWidth={2.4} aria-hidden />
|
||||
<span className="truncate">Library</span>
|
||||
</Link>
|
||||
</ViewTransitionLink>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
SidebarItemNavigationContext,
|
||||
SidebarItemDetailsReadyContext,
|
||||
type BeginSidebarItemNavigation,
|
||||
} from "@/components/sidebarItemNavigationContext";
|
||||
|
||||
export default function SidebarItemNavigationProvider({
|
||||
begin,
|
||||
itemDetailsReady,
|
||||
children,
|
||||
}: {
|
||||
begin: BeginSidebarItemNavigation;
|
||||
itemDetailsReady: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<SidebarItemNavigationContext.Provider value={begin}>
|
||||
<SidebarItemDetailsReadyContext.Provider value={itemDetailsReady}>
|
||||
{children}
|
||||
</SidebarItemDetailsReadyContext.Provider>
|
||||
</SidebarItemNavigationContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter, useLocation } from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import SidebarItemNavigationProvider from "./SidebarItemNavigationProvider";
|
||||
import ViewTransitionLink from "./ViewTransitionLink";
|
||||
|
||||
function LocationOutput() {
|
||||
const location = useLocation();
|
||||
return <output aria-label="location">{location.pathname}</output>;
|
||||
}
|
||||
|
||||
describe("ViewTransitionLink sidebar navigation", () => {
|
||||
it("lets Layout intercept an item navigation with its state intact", () => {
|
||||
const begin = vi.fn(() => true);
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<SidebarItemNavigationProvider begin={begin} itemDetailsReady>
|
||||
<ViewTransitionLink to="/item/movie-1?libraryId=2" state={{ source: "home" }}>
|
||||
Movie
|
||||
</ViewTransitionLink>
|
||||
<LocationOutput />
|
||||
</SidebarItemNavigationProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("link", { name: "Movie" }));
|
||||
|
||||
expect(begin).toHaveBeenCalledWith({
|
||||
href: "/item/movie-1?libraryId=2",
|
||||
replace: undefined,
|
||||
state: { source: "home" },
|
||||
});
|
||||
expect(screen.getByRole("status", { name: "location" })).toHaveTextContent("/");
|
||||
});
|
||||
|
||||
it("honors a caller that prevents navigation", () => {
|
||||
const begin = vi.fn(() => true);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SidebarItemNavigationProvider begin={begin} itemDetailsReady>
|
||||
<ViewTransitionLink to="/item/movie-1" onClick={(event) => event.preventDefault()}>
|
||||
Movie
|
||||
</ViewTransitionLink>
|
||||
</SidebarItemNavigationProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("link", { name: "Movie" }));
|
||||
expect(begin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["meta", { metaKey: true }],
|
||||
["control", { ctrlKey: true }],
|
||||
["shift", { shiftKey: true }],
|
||||
["alt", { altKey: true }],
|
||||
])("does not intercept a %s-modified click", (_label, init) => {
|
||||
const begin = vi.fn(() => true);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SidebarItemNavigationProvider begin={begin} itemDetailsReady>
|
||||
<ViewTransitionLink to="/item/movie-1">Movie</ViewTransitionLink>
|
||||
</SidebarItemNavigationProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
const event = new MouseEvent("click", { bubbles: true, cancelable: true, ...init });
|
||||
|
||||
fireEvent(screen.getByRole("link", { name: "Movie" }), event);
|
||||
|
||||
expect(begin).not.toHaveBeenCalled();
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it("does not intercept a middle click", () => {
|
||||
const begin = vi.fn(() => true);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SidebarItemNavigationProvider begin={begin} itemDetailsReady>
|
||||
<ViewTransitionLink to="/item/movie-1">Movie</ViewTransitionLink>
|
||||
</SidebarItemNavigationProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
const event = new MouseEvent("click", { bubbles: true, cancelable: true, button: 1 });
|
||||
|
||||
fireEvent(screen.getByRole("link", { name: "Movie" }), event);
|
||||
|
||||
expect(begin).not.toHaveBeenCalled();
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it("does not intercept a link that opens a new browsing context", () => {
|
||||
const begin = vi.fn(() => true);
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SidebarItemNavigationProvider begin={begin} itemDetailsReady>
|
||||
<ViewTransitionLink to="/item/movie-1" target="_blank">
|
||||
Movie
|
||||
</ViewTransitionLink>
|
||||
</SidebarItemNavigationProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
const event = new MouseEvent("click", { bubbles: true, cancelable: true });
|
||||
|
||||
fireEvent(screen.getByRole("link", { name: "Movie" }), event);
|
||||
|
||||
expect(begin).not.toHaveBeenCalled();
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it("falls through to router navigation when Layout declines interception", () => {
|
||||
const begin = vi.fn(() => false);
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<SidebarItemNavigationProvider begin={begin} itemDetailsReady>
|
||||
<ViewTransitionLink to="/item/movie-1">Movie</ViewTransitionLink>
|
||||
<LocationOutput />
|
||||
</SidebarItemNavigationProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("link", { name: "Movie" }));
|
||||
|
||||
expect(begin).toHaveBeenCalledOnce();
|
||||
expect(screen.getByRole("status", { name: "location" })).toHaveTextContent("/item/movie-1");
|
||||
});
|
||||
|
||||
it("behaves as a plain router link without a provider", () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/"]}>
|
||||
<ViewTransitionLink to="/item/movie-1">Movie</ViewTransitionLink>
|
||||
<LocationOutput />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("link", { name: "Movie" }));
|
||||
|
||||
expect(screen.getByRole("status", { name: "location" })).toHaveTextContent("/item/movie-1");
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,46 @@
|
||||
import { forwardRef } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { createPath, Link, useResolvedPath } from "react-router";
|
||||
import type { LinkProps } from "react-router";
|
||||
import { useSidebarItemNavigation } from "@/components/sidebarItemNavigationContext";
|
||||
|
||||
/**
|
||||
* A thin wrapper around React Router's `<Link>` that opts into
|
||||
* the router's built-in view transition support.
|
||||
* Opts into React Router view transitions and lets the desktop layout prepare
|
||||
* item-detail navigation so its heavy content cannot overlap sidebar motion.
|
||||
*/
|
||||
const ViewTransitionLink = forwardRef<
|
||||
HTMLAnchorElement,
|
||||
LinkProps & React.AnchorHTMLAttributes<HTMLAnchorElement>
|
||||
>(function ViewTransitionLink({ to, replace, state, onClick, children, ...rest }, ref) {
|
||||
const beginSidebarItemNavigation = useSidebarItemNavigation();
|
||||
const resolvedPath = useResolvedPath(to);
|
||||
|
||||
return (
|
||||
<Link
|
||||
ref={ref}
|
||||
to={to}
|
||||
replace={replace}
|
||||
state={state}
|
||||
onClick={onClick}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.button !== 0 ||
|
||||
event.metaKey ||
|
||||
event.ctrlKey ||
|
||||
event.shiftKey ||
|
||||
event.altKey ||
|
||||
(rest.target && rest.target !== "_self")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const intercepted = beginSidebarItemNavigation?.({
|
||||
href: createPath(resolvedPath),
|
||||
replace,
|
||||
state,
|
||||
});
|
||||
if (intercepted) event.preventDefault();
|
||||
}}
|
||||
viewTransition
|
||||
{...rest}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
hasRunningSidebarTransition,
|
||||
isCollapsedSidebarSurface,
|
||||
parseOptionalLibraryId,
|
||||
parseItemNavigationHref,
|
||||
SIDEBAR_TRANSITION_FALLBACK_MS,
|
||||
sidebarDetailsRevealDelay,
|
||||
} from "./sidebarItemNavigation";
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("sidebar collapse completion", () => {
|
||||
it("accepts only the collapsed sidebar surface", () => {
|
||||
const surface = document.createElement("aside");
|
||||
surface.classList.add("sidebar-surface");
|
||||
|
||||
expect(isCollapsedSidebarSurface(surface)).toBe(false);
|
||||
|
||||
surface.dataset.collapsed = "true";
|
||||
expect(isCollapsedSidebarSurface(surface)).toBe(true);
|
||||
|
||||
surface.classList.remove("sidebar-surface");
|
||||
expect(isCollapsedSidebarSurface(surface)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when the Web Animations API is unavailable", () => {
|
||||
const surface = document.createElement("aside");
|
||||
Object.defineProperty(surface, "getAnimations", { value: undefined });
|
||||
|
||||
expect(hasRunningSidebarTransition(surface)).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores running animations that are not the transform transition", () => {
|
||||
class FakeTransition {
|
||||
playState = "running";
|
||||
constructor(readonly transitionProperty: string) {}
|
||||
}
|
||||
vi.stubGlobal("CSSTransition", FakeTransition);
|
||||
const surface = document.createElement("aside");
|
||||
Object.defineProperty(surface, "getAnimations", {
|
||||
value: () => [new FakeTransition("opacity"), { playState: "running" }],
|
||||
});
|
||||
|
||||
expect(hasRunningSidebarTransition(surface)).toBe(false);
|
||||
});
|
||||
|
||||
it("detects a running transform transition", () => {
|
||||
class FakeTransition {
|
||||
playState = "running";
|
||||
transitionProperty = "transform";
|
||||
}
|
||||
vi.stubGlobal("CSSTransition", FakeTransition);
|
||||
const surface = document.createElement("aside");
|
||||
Object.defineProperty(surface, "getAnimations", {
|
||||
value: () => [new FakeTransition()],
|
||||
});
|
||||
|
||||
expect(hasRunningSidebarTransition(surface)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sidebarDetailsRevealDelay", () => {
|
||||
it("uses the transition fallback for motion and reveals immediately for reduced motion", () => {
|
||||
expect(sidebarDetailsRevealDelay(false)).toBe(SIDEBAR_TRANSITION_FALLBACK_MS);
|
||||
expect(sidebarDetailsRevealDelay(true)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseItemNavigationHref", () => {
|
||||
it("parses an encoded item id and optional library id", () => {
|
||||
expect(
|
||||
parseItemNavigationHref("/item/movie%201%2Fpart?libraryId=12", "http://localhost:5173"),
|
||||
).toEqual({ contentId: "movie 1/part", libraryId: 12 });
|
||||
});
|
||||
|
||||
it("accepts an item without a library id", () => {
|
||||
expect(parseItemNavigationHref("/item/movie-1", "http://localhost:5173")).toEqual({
|
||||
contentId: "movie-1",
|
||||
libraryId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-item, cross-origin, and malformed destinations", () => {
|
||||
expect(parseItemNavigationHref("/library/1", "http://localhost:5173")).toBeNull();
|
||||
expect(
|
||||
parseItemNavigationHref("https://example.com/item/movie-1", "http://localhost:5173"),
|
||||
).toBeNull();
|
||||
expect(parseItemNavigationHref("/item/%E0%A4%A", "http://localhost:5173")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseOptionalLibraryId", () => {
|
||||
it("accepts finite ids and rejects malformed ones", () => {
|
||||
expect(parseOptionalLibraryId("12")).toBe(12);
|
||||
expect(parseOptionalLibraryId("abc")).toBeUndefined();
|
||||
expect(parseOptionalLibraryId(null)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
export const SIDEBAR_COLLAPSE_DURATION_MS = 300;
|
||||
export const SIDEBAR_TRANSITION_FALLBACK_MS = SIDEBAR_COLLAPSE_DURATION_MS + 80;
|
||||
// Hover expansion and hidden-tab rAF suspension must never hold the detail
|
||||
// shell indefinitely. Settling is preferred, but this is the absolute cap.
|
||||
export const SIDEBAR_DETAILS_REVEAL_DEADLINE_MS = SIDEBAR_TRANSITION_FALLBACK_MS * 2;
|
||||
|
||||
export function sidebarDetailsRevealDelay(reduceMotion: boolean): number {
|
||||
return reduceMotion ? 0 : SIDEBAR_TRANSITION_FALLBACK_MS;
|
||||
}
|
||||
|
||||
export function isCollapsedSidebarSurface(target: EventTarget | null): target is HTMLElement {
|
||||
return (
|
||||
target instanceof HTMLElement &&
|
||||
target.classList.contains("sidebar-surface") &&
|
||||
target.dataset.collapsed === "true"
|
||||
);
|
||||
}
|
||||
|
||||
export function hasRunningSidebarTransition(surface: HTMLElement): boolean {
|
||||
if (typeof surface.getAnimations !== "function") return false;
|
||||
const Transition = globalThis.CSSTransition;
|
||||
if (typeof Transition === "undefined") return false;
|
||||
return surface
|
||||
.getAnimations()
|
||||
.some(
|
||||
(animation) =>
|
||||
animation instanceof Transition &&
|
||||
animation.transitionProperty === "transform" &&
|
||||
animation.playState === "running",
|
||||
);
|
||||
}
|
||||
|
||||
export function parseOptionalLibraryId(rawLibraryId: string | null): number | undefined {
|
||||
if (!rawLibraryId) return undefined;
|
||||
const parsedLibraryId = Number(rawLibraryId);
|
||||
return Number.isFinite(parsedLibraryId) ? parsedLibraryId : undefined;
|
||||
}
|
||||
|
||||
export interface SidebarItemNavigationRequest {
|
||||
href: string;
|
||||
replace?: boolean;
|
||||
state?: unknown;
|
||||
}
|
||||
|
||||
export function parseItemNavigationHref(
|
||||
href: string,
|
||||
origin: string,
|
||||
): { contentId: string; libraryId?: number } | null {
|
||||
try {
|
||||
const destination = new URL(href, origin);
|
||||
if (destination.origin !== origin || !destination.pathname.startsWith("/item/")) return null;
|
||||
|
||||
const contentId = decodeURIComponent(destination.pathname.slice("/item/".length));
|
||||
if (!contentId) return null;
|
||||
return {
|
||||
contentId,
|
||||
libraryId: parseOptionalLibraryId(destination.searchParams.get("libraryId")),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import type { SidebarItemNavigationRequest } from "@/components/sidebarItemNavigation";
|
||||
|
||||
export type BeginSidebarItemNavigation = (request: SidebarItemNavigationRequest) => boolean;
|
||||
|
||||
export const SidebarItemNavigationContext = createContext<BeginSidebarItemNavigation | null>(null);
|
||||
export const SidebarItemDetailsReadyContext = createContext(true);
|
||||
|
||||
export function useSidebarItemNavigation(): BeginSidebarItemNavigation | null {
|
||||
return useContext(SidebarItemNavigationContext);
|
||||
}
|
||||
|
||||
export function useSidebarItemDetailsReady(): boolean {
|
||||
return useContext(SidebarItemDetailsReadyContext);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useImmediateSidebarCollapse } from "./useImmediateSidebarCollapse";
|
||||
|
||||
function stubFrames() {
|
||||
const queue: FrameRequestCallback[] = [];
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||
queue.push(callback);
|
||||
return queue.length;
|
||||
});
|
||||
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
||||
return {
|
||||
get pending() {
|
||||
return queue.length;
|
||||
},
|
||||
async frame() {
|
||||
const callback = queue.shift();
|
||||
expect(callback, "no frame was requested").toBeTypeOf("function");
|
||||
await act(async () => callback!(performance.now()));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function stubReducedMotion(reduce: boolean) {
|
||||
let matches = reduce;
|
||||
const listeners = new Set<(event: MediaQueryListEvent) => void>();
|
||||
const mediaQuery = {
|
||||
get matches() {
|
||||
return matches;
|
||||
},
|
||||
media: "(prefers-reduced-motion: reduce)",
|
||||
addEventListener(_type: string, listener: (event: MediaQueryListEvent) => void) {
|
||||
listeners.add(listener);
|
||||
},
|
||||
removeEventListener(_type: string, listener: (event: MediaQueryListEvent) => void) {
|
||||
listeners.delete(listener);
|
||||
},
|
||||
addListener() {},
|
||||
removeListener() {},
|
||||
onchange: null,
|
||||
dispatchEvent: () => false,
|
||||
};
|
||||
vi.stubGlobal("matchMedia", (query: string) => ({
|
||||
...mediaQuery,
|
||||
matches: matches && query.includes("prefers-reduced-motion: reduce"),
|
||||
media: query,
|
||||
addEventListener: mediaQuery.addEventListener,
|
||||
removeEventListener: mediaQuery.removeEventListener,
|
||||
}));
|
||||
return {
|
||||
set(next: boolean) {
|
||||
matches = next;
|
||||
const event = { matches: next } as MediaQueryListEvent;
|
||||
for (const listener of listeners) listener(event);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("useImmediateSidebarCollapse", () => {
|
||||
it("passes the initial state straight through", () => {
|
||||
stubFrames();
|
||||
stubReducedMotion(false);
|
||||
expect(renderHook(() => useImmediateSidebarCollapse(true)).result.current).toBe(true);
|
||||
});
|
||||
|
||||
it("starts the visual collapse on the next frame", async () => {
|
||||
const frames = stubFrames();
|
||||
stubReducedMotion(false);
|
||||
const { result, rerender } = renderHook(
|
||||
({ collapsed }) => useImmediateSidebarCollapse(collapsed),
|
||||
{
|
||||
initialProps: { collapsed: false },
|
||||
},
|
||||
);
|
||||
|
||||
rerender({ collapsed: true });
|
||||
expect(result.current).toBe(false);
|
||||
expect(frames.pending).toBe(1);
|
||||
|
||||
await frames.frame();
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it("applies reduced motion without scheduling a frame", () => {
|
||||
const frames = stubFrames();
|
||||
stubReducedMotion(true);
|
||||
const { result, rerender } = renderHook(
|
||||
({ collapsed }) => useImmediateSidebarCollapse(collapsed),
|
||||
{
|
||||
initialProps: { collapsed: false },
|
||||
},
|
||||
);
|
||||
|
||||
act(() => rerender({ collapsed: true }));
|
||||
expect(result.current).toBe(true);
|
||||
expect(frames.pending).toBe(0);
|
||||
});
|
||||
|
||||
it("observes preference changes without a stale catch-up transition", () => {
|
||||
const frames = stubFrames();
|
||||
const motion = stubReducedMotion(false);
|
||||
const { result, rerender } = renderHook(
|
||||
({ collapsed }) => useImmediateSidebarCollapse(collapsed),
|
||||
{ initialProps: { collapsed: false } },
|
||||
);
|
||||
|
||||
act(() => motion.set(true));
|
||||
rerender({ collapsed: true });
|
||||
expect(result.current).toBe(true);
|
||||
expect(frames.pending).toBe(0);
|
||||
|
||||
act(() => motion.set(false));
|
||||
expect(result.current).toBe(true);
|
||||
expect(frames.pending).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export function prefersReducedMotion(): boolean {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
typeof window.matchMedia === "function" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Separates the layout snap from the visible compositor transition by one
|
||||
* animation frame. This is the minimum handoff needed for the inverse main
|
||||
* transform to take effect before the sidebar starts moving; it never waits on
|
||||
* item metadata, poster decoding, backdrop loading, or frame-rate heuristics.
|
||||
*/
|
||||
export function useImmediateSidebarCollapse(collapsed: boolean): boolean {
|
||||
const [visualCollapsed, setVisualCollapsed] = useState(collapsed);
|
||||
const [reduceMotion, setReduceMotion] = useState(prefersReducedMotion);
|
||||
const frameRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
|
||||
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const handleChange = (event: MediaQueryListEvent) => {
|
||||
// Synchronize the internal visual state on both edges so disabling the
|
||||
// preference cannot play a stale catch-up transition.
|
||||
setVisualCollapsed(collapsed);
|
||||
setReduceMotion(event.matches);
|
||||
};
|
||||
query.addEventListener("change", handleChange);
|
||||
return () => query.removeEventListener("change", handleChange);
|
||||
}, [collapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visualCollapsed === collapsed || reduceMotion) return;
|
||||
|
||||
if (typeof requestAnimationFrame !== "function") {
|
||||
const timer = window.setTimeout(() => setVisualCollapsed(collapsed), 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}
|
||||
|
||||
frameRef.current = requestAnimationFrame(() => setVisualCollapsed(collapsed));
|
||||
return () => cancelAnimationFrame(frameRef.current);
|
||||
}, [collapsed, reduceMotion, visualCollapsed]);
|
||||
|
||||
return reduceMotion ? collapsed : visualCollapsed;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { useSidebarItemDetailsGate } from "./useSidebarItemDetailsGate";
|
||||
|
||||
describe("useSidebarItemDetailsGate", () => {
|
||||
it("gates every non-item to item entry, including re-entering a history entry", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ locationKey, isItem }: { locationKey: string; isItem: boolean }) =>
|
||||
useSidebarItemDetailsGate(locationKey, isItem),
|
||||
{ initialProps: { locationKey: "catalog", isItem: false } },
|
||||
);
|
||||
|
||||
rerender({ locationKey: "movie", isItem: true });
|
||||
expect(result.current.itemDetailsReady).toBe(false);
|
||||
|
||||
act(() => result.current.reveal("movie"));
|
||||
expect(result.current.itemDetailsReady).toBe(true);
|
||||
|
||||
rerender({ locationKey: "catalog", isItem: false });
|
||||
rerender({ locationKey: "movie", isItem: true });
|
||||
expect(result.current.itemDetailsReady).toBe(false);
|
||||
});
|
||||
|
||||
it("does not gate a direct initial item render", () => {
|
||||
const { result } = renderHook(() => useSidebarItemDetailsGate("movie", true));
|
||||
|
||||
expect(result.current.itemDetailsReady).toBe(true);
|
||||
expect(result.current.pendingLocationKey).toBeNull();
|
||||
});
|
||||
|
||||
it("discards an abandoned gate and allows the next item entry", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ locationKey, isItem }: { locationKey: string; isItem: boolean }) =>
|
||||
useSidebarItemDetailsGate(locationKey, isItem),
|
||||
{ initialProps: { locationKey: "catalog", isItem: false } },
|
||||
);
|
||||
|
||||
rerender({ locationKey: "abandoned-movie", isItem: true });
|
||||
expect(result.current.itemDetailsReady).toBe(false);
|
||||
|
||||
rerender({ locationKey: "search", isItem: false });
|
||||
expect(result.current.itemDetailsReady).toBe(true);
|
||||
|
||||
rerender({ locationKey: "next-movie", isItem: true });
|
||||
expect(result.current.itemDetailsReady).toBe(false);
|
||||
|
||||
act(() => result.current.reveal("abandoned-movie"));
|
||||
expect(result.current.itemDetailsReady).toBe(false);
|
||||
|
||||
act(() => result.current.reveal("next-movie"));
|
||||
expect(result.current.itemDetailsReady).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
interface ItemDetailsGateState {
|
||||
locationKey: string;
|
||||
gatesItemDetails: boolean;
|
||||
pendingLocationKey: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps item details lightweight while a desktop route entry collapses the
|
||||
* sidebar. The gate follows committed route transitions instead of individual
|
||||
* click handlers, so POP and programmatic navigation receive the same
|
||||
* treatment and abandoned navigation cannot leave a stale global lock.
|
||||
*/
|
||||
export function useSidebarItemDetailsGate(locationKey: string, gatesItemDetails: boolean) {
|
||||
const [state, setState] = useState<ItemDetailsGateState>({
|
||||
locationKey,
|
||||
gatesItemDetails,
|
||||
pendingLocationKey: null,
|
||||
});
|
||||
|
||||
let currentState = state;
|
||||
if (state.locationKey !== locationKey || state.gatesItemDetails !== gatesItemDetails) {
|
||||
currentState = {
|
||||
locationKey,
|
||||
gatesItemDetails,
|
||||
pendingLocationKey: gatesItemDetails && !state.gatesItemDetails ? locationKey : null,
|
||||
};
|
||||
// React discards this render and retries before rendering children, so the
|
||||
// item route never briefly receives `itemDetailsReady=true` on entry.
|
||||
setState(currentState);
|
||||
}
|
||||
|
||||
const reveal = useCallback((expectedLocationKey: string) => {
|
||||
setState((current) =>
|
||||
current.pendingLocationKey === expectedLocationKey
|
||||
? { ...current, pendingLocationKey: null }
|
||||
: current,
|
||||
);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
itemDetailsReady: !gatesItemDetails || currentState.pendingLocationKey === null,
|
||||
pendingLocationKey: currentState.pendingLocationKey,
|
||||
reveal,
|
||||
};
|
||||
}
|
||||
@@ -8,16 +8,23 @@ describe("DetailHero artwork revisions", () => {
|
||||
const { rerender } = render(<DetailHero title="Blade Runner" posterUrl="/poster.rev-a.webp" />);
|
||||
|
||||
const first = screen.getByRole("img", { name: "Blade Runner" });
|
||||
const firstPlaceholder = screen.getByTestId("detail-hero-poster-placeholder");
|
||||
expect(first).toHaveClass("opacity-0");
|
||||
expect(first).not.toHaveClass("transition-opacity");
|
||||
expect(firstPlaceholder).toHaveClass("opacity-100", "transition-opacity");
|
||||
fireEvent.load(first);
|
||||
expect(first).toHaveClass("opacity-100");
|
||||
expect(firstPlaceholder).toHaveClass("opacity-0");
|
||||
|
||||
rerender(<DetailHero title="Blade Runner" posterUrl="/poster.rev-b.webp" />);
|
||||
|
||||
const replacement = screen.getByRole("img", { name: "Blade Runner" });
|
||||
const replacementPlaceholder = screen.getByTestId("detail-hero-poster-placeholder");
|
||||
expect(replacement).toHaveAttribute("src", "/poster.rev-b.webp");
|
||||
expect(replacement).toHaveClass("opacity-0");
|
||||
expect(replacementPlaceholder).toHaveClass("opacity-100");
|
||||
fireEvent.load(replacement);
|
||||
expect(replacement).toHaveClass("opacity-100");
|
||||
expect(replacementPlaceholder).toHaveClass("opacity-0");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -152,25 +152,31 @@ export default function DetailHero({
|
||||
{/* Poster */}
|
||||
{!hidePoster && (
|
||||
<div
|
||||
className={`media-card-image border-border/20 border shadow-[var(--shadow-md)] ${posterSizeClass}`}
|
||||
style={
|
||||
posterPlaceholder && !posterLoaded
|
||||
? {
|
||||
backgroundImage: `url(${posterPlaceholder})`,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className={`media-card-image border-border/20 relative border shadow-[var(--shadow-md)] ${posterSizeClass}`}
|
||||
>
|
||||
{posterUrl ? (
|
||||
<img
|
||||
key={posterUrl}
|
||||
src={posterUrl}
|
||||
alt={title}
|
||||
className={`w-full object-cover ${posterAspect} transition-opacity duration-300 ${posterLoaded ? "opacity-100" : "opacity-0"}`}
|
||||
onLoad={onPosterLoad}
|
||||
/>
|
||||
<>
|
||||
<img
|
||||
key={posterUrl}
|
||||
src={posterUrl}
|
||||
alt={title}
|
||||
className={`w-full object-cover ${posterAspect} ${posterLoaded ? "opacity-100" : "opacity-0"}`}
|
||||
onLoad={onPosterLoad}
|
||||
/>
|
||||
<span
|
||||
key={`placeholder-${posterUrl}`}
|
||||
aria-hidden="true"
|
||||
data-testid="detail-hero-poster-placeholder"
|
||||
className={`bg-surface pointer-events-none absolute inset-0 bg-cover bg-center transition-opacity duration-300 ${
|
||||
posterLoaded ? "opacity-0" : "opacity-100"
|
||||
}`}
|
||||
style={
|
||||
posterPlaceholder
|
||||
? { backgroundImage: `url(${posterPlaceholder})` }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
className={`text-muted-foreground bg-surface flex items-center justify-center p-6 text-center text-sm ${posterAspect}`}
|
||||
|
||||
@@ -4,11 +4,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const mocks = vi.hoisted(() => ({
|
||||
useCatalogItemDetail: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
search: "",
|
||||
}));
|
||||
|
||||
vi.mock("react-router", () => ({
|
||||
useParams: () => ({ id: "movie-123" }),
|
||||
useSearchParams: () => [new URLSearchParams()],
|
||||
useSearchParams: () => [new URLSearchParams(mocks.search)],
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/queries/catalogRead", () => ({
|
||||
@@ -46,11 +47,13 @@ vi.mock("@/pages/ItemDetail/EbookContent", () => ({
|
||||
}));
|
||||
|
||||
import ItemDetail from "./index";
|
||||
import { SidebarItemDetailsReadyContext } from "@/components/sidebarItemNavigationContext";
|
||||
|
||||
describe("ItemDetail", () => {
|
||||
beforeEach(() => {
|
||||
mocks.useCatalogItemDetail.mockReset();
|
||||
mocks.toastError.mockReset();
|
||||
mocks.search = "";
|
||||
mocks.useCatalogItemDetail.mockReturnValue({
|
||||
data: { content_id: "movie-123", title: "Catalog Detail", type: "movie" },
|
||||
isLoading: false,
|
||||
@@ -58,6 +61,14 @@ describe("ItemDetail", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores a malformed library id so the detail query matches the prefetch key", () => {
|
||||
mocks.search = "libraryId=abc";
|
||||
|
||||
renderToStaticMarkup(<ItemDetail />);
|
||||
|
||||
expect(mocks.useCatalogItemDetail).toHaveBeenCalledWith("movie-123", undefined);
|
||||
});
|
||||
|
||||
it("reads item detail through the canonical catalog detail hook", () => {
|
||||
const markup = renderToStaticMarkup(<ItemDetail />);
|
||||
|
||||
@@ -65,6 +76,17 @@ describe("ItemDetail", () => {
|
||||
expect(mocks.useCatalogItemDetail).toHaveBeenCalledWith("movie-123", undefined);
|
||||
});
|
||||
|
||||
it("keeps the lightweight shell mounted until the sidebar transition finishes", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<SidebarItemDetailsReadyContext.Provider value={false}>
|
||||
<ItemDetail />
|
||||
</SidebarItemDetailsReadyContext.Provider>,
|
||||
);
|
||||
|
||||
expect(markup).not.toContain("Catalog Detail");
|
||||
expect(markup).toContain("animate-pulse");
|
||||
});
|
||||
|
||||
it("routes ebook items to ebook detail content", () => {
|
||||
mocks.useCatalogItemDetail.mockReturnValue({
|
||||
data: { content_id: "ebook-123", title: "A Psalm for the Wild-Built", type: "ebook" },
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
CrewSkeleton,
|
||||
RecommendationGridSkeleton,
|
||||
} from "./components/SectionSkeletons";
|
||||
import { useSidebarItemDetailsReady } from "@/components/sidebarItemNavigationContext";
|
||||
import { parseOptionalLibraryId } from "@/components/sidebarItemNavigation";
|
||||
|
||||
function ItemDetailSkeleton() {
|
||||
return (
|
||||
@@ -72,9 +74,9 @@ function ItemDetailSkeleton() {
|
||||
export default function ItemDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const libraryIdParam = searchParams.get("libraryId");
|
||||
const libraryId = libraryIdParam ? Number(libraryIdParam) : undefined;
|
||||
const libraryId = parseOptionalLibraryId(searchParams.get("libraryId"));
|
||||
const { data: item, isLoading: loading, error: itemError } = useCatalogItemDetail(id, libraryId);
|
||||
const itemDetailsReady = useSidebarItemDetailsReady();
|
||||
|
||||
useDocumentTitle(item?.title ?? "Item");
|
||||
|
||||
@@ -84,7 +86,7 @@ export default function ItemDetail() {
|
||||
}
|
||||
}, [itemError]);
|
||||
|
||||
if (loading) {
|
||||
if (loading || !itemDetailsReady) {
|
||||
return <ItemDetailSkeleton />;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Fragment, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { Bell, BellOff, Check, CheckCheck, Loader2, Settings2 } from "lucide-react";
|
||||
import type { AppNotification } from "@/api/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
|
||||
import { decodeThumbhash } from "@/lib/thumbhash";
|
||||
import { preferredDateLocale } from "@/lib/datetime";
|
||||
import ViewTransitionLink from "@/components/ViewTransitionLink";
|
||||
|
||||
function formatNotificationTime(value: string): string {
|
||||
const date = new Date(value);
|
||||
@@ -180,7 +180,7 @@ function NotificationRow({
|
||||
return (
|
||||
<li className="group relative">
|
||||
{detailHref ? (
|
||||
<Link
|
||||
<ViewTransitionLink
|
||||
to={detailHref}
|
||||
onClick={() => unread && onMarkRead(notification.id)}
|
||||
className={`hover:bg-muted/60 flex items-start gap-3 rounded-xl px-3 py-3 transition-colors ${
|
||||
@@ -188,7 +188,7 @@ function NotificationRow({
|
||||
}`}
|
||||
>
|
||||
{body}
|
||||
</Link>
|
||||
</ViewTransitionLink>
|
||||
) : (
|
||||
<div
|
||||
className={`flex items-start gap-3 rounded-xl px-3 py-3 ${unread ? "bg-muted/30" : ""}`}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Link, useParams } from "react-router";
|
||||
import { useParams } from "react-router";
|
||||
import { Check, Clock, Library, Loader2, Plus, Star } from "lucide-react";
|
||||
import CastCarousel from "@/components/CastCarousel";
|
||||
import MediaCarousel from "@/components/MediaCarousel";
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
requestInputFromMediaResult,
|
||||
tmdbImageURL,
|
||||
} from "@/lib/mediaRequests";
|
||||
import ViewTransitionLink from "@/components/ViewTransitionLink";
|
||||
|
||||
export default function RequestDetail() {
|
||||
const params = useParams<{ mediaType: string; tmdbId: string }>();
|
||||
@@ -239,10 +240,10 @@ function RequestActions({
|
||||
variant="outline"
|
||||
className="border-border/60 h-11 rounded-full px-5 text-sm font-semibold"
|
||||
>
|
||||
<Link to={`/item/${encodeURIComponent(item.library_content_id)}`}>
|
||||
<ViewTransitionLink to={`/item/${encodeURIComponent(item.library_content_id)}`}>
|
||||
<Library className="h-4 w-4" />
|
||||
Open in library
|
||||
</Link>
|
||||
</ViewTransitionLink>
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SIDEBAR_COLLAPSE_DURATION_MS } from "./components/sidebarItemNavigation";
|
||||
import { SIDEBAR_RAIL_WIDTH, SIDEBAR_SURFACE_WIDTH } from "./components/AppSidebar.logic";
|
||||
|
||||
/**
|
||||
* The sidebar-collapse behaviour lives mostly in CSS, so these are contract
|
||||
* tests over app.css rather than over rendered markup.
|
||||
*
|
||||
* The rules that matter: the sidebar surface stays 260px wide in every state,
|
||||
* entering /item/* snaps the real layout from a 260px to a 64px offset in one
|
||||
* pass, and the only animated property is `transform`. Transform is the one
|
||||
* choice Chrome runs on the compositor — a clipping-path version of this was
|
||||
* swallowed whole by the detail page's render (267ms and 209ms frozen frames).
|
||||
*/
|
||||
const css = readFileSync(fileURLToPath(new URL("./app.css", import.meta.url)), "utf8");
|
||||
|
||||
/**
|
||||
* Body of the rule whose selector list starts at `selector`, searched from
|
||||
* after the reduced-motion block so the `transition: none` overrides in there
|
||||
* are never mistaken for the real declarations.
|
||||
*/
|
||||
function ruleBody(selector: string): string {
|
||||
// This anchor is intentionally after reduced motion, whose selector list
|
||||
// also contains `.sidebar-main-stage` but only declares transition: none.
|
||||
const from = css.indexOf("@media (forced-colors: active)");
|
||||
expect(from, "forced-colors anchor must remain after reduced motion").toBeGreaterThan(-1);
|
||||
const start = css.indexOf(selector, from);
|
||||
expect(start, `missing rule: ${selector}`).toBeGreaterThan(-1);
|
||||
return css.slice(css.indexOf("{", start), css.indexOf("}", start));
|
||||
}
|
||||
|
||||
/** Body of the first `@media (prefers-reduced-motion: reduce)` block. */
|
||||
function reducedMotionBlock(): string {
|
||||
const start = css.indexOf("@media (prefers-reduced-motion: reduce)");
|
||||
expect(start).toBeGreaterThan(-1);
|
||||
let depth = 0;
|
||||
for (let i = css.indexOf("{", start); i < css.length; i++) {
|
||||
if (css[i] === "{") depth++;
|
||||
else if (css[i] === "}" && --depth === 0) return css.slice(start, i + 1);
|
||||
}
|
||||
throw new Error("unterminated prefers-reduced-motion block");
|
||||
}
|
||||
|
||||
describe("sidebar collapse CSS", () => {
|
||||
it("animates transform and nothing else on the surface", () => {
|
||||
const surface = ruleBody(".sidebar-surface {");
|
||||
expect(surface).toContain("overflow: hidden");
|
||||
expect(surface).toContain("transform: translateX(0)");
|
||||
expect(surface).toMatch(/transition:\s*transform var\(--duration-sidebar-collapse\)/);
|
||||
// Any layout property here would put the document back on the reflow path,
|
||||
// and any non-composited property would be starved by the route render.
|
||||
expect(surface).not.toMatch(/\b(width|margin-left|left|clip-path|grid-template-columns)\s*:/);
|
||||
});
|
||||
|
||||
it("slides the frame left by exactly the 196px it has to give up", () => {
|
||||
const travel = SIDEBAR_SURFACE_WIDTH - SIDEBAR_RAIL_WIDTH;
|
||||
expect(ruleBody('.sidebar-surface[data-collapsed="true"] {')).toContain(
|
||||
`transform: translateX(-${travel}px)`,
|
||||
);
|
||||
});
|
||||
|
||||
it("counter-translates the contents by the same distance so nothing moves on screen", () => {
|
||||
const inner = ruleBody(".sidebar-inner {");
|
||||
expect(inner).toContain("transform: translateX(0)");
|
||||
expect(inner).toMatch(/transition:\s*transform var\(--duration-sidebar-collapse\)/);
|
||||
expect(ruleBody('.sidebar-surface[data-collapsed="true"] .sidebar-inner {')).toContain(
|
||||
`transform: translateX(${SIDEBAR_SURFACE_WIDTH - SIDEBAR_RAIL_WIDTH}px)`,
|
||||
);
|
||||
});
|
||||
|
||||
it("moves the main stage on the same compositor timeline as the sidebar", () => {
|
||||
const main = ruleBody(".sidebar-main-stage {");
|
||||
expect(main).toContain("transform: none");
|
||||
expect(main).toMatch(/transition:\s*transform var\(--duration-sidebar-collapse\)/);
|
||||
const entering = ruleBody('.sidebar-main-stage[data-sidebar-target-collapsed="true"]:not(');
|
||||
expect(entering).toContain("transform: translateX(196px)");
|
||||
expect(entering).toContain("transition: none");
|
||||
expect(css).toMatch(
|
||||
/\.sidebar-main-stage:not\([\s\S]*?data-sidebar-target-collapsed="true"[\s\S]*?\)\[data-sidebar-visual-collapsed="true"\]\s*\{\s*transform:\s*translateX\(-196px\);\s*transition:\s*none/,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the shared collapse duration on a compositor-friendly ease", () => {
|
||||
const duration = css.match(/--duration-sidebar-collapse:\s*(\d+)ms/);
|
||||
expect(duration).not.toBeNull();
|
||||
expect(Number(duration![1])).toBe(SIDEBAR_COLLAPSE_DURATION_MS);
|
||||
expect(css).toMatch(/--ease-sidebar-collapse:\s*cubic-bezier\(/);
|
||||
});
|
||||
|
||||
it("cross-fades and translates sidebar contents rather than resizing them", () => {
|
||||
expect(ruleBody(".sidebar-fade {")).toMatch(
|
||||
/transition:\s*opacity var\(--duration-sidebar-collapse\)/,
|
||||
);
|
||||
expect(ruleBody(".sidebar-row-shift {")).toMatch(
|
||||
/transition:\s*transform var\(--duration-sidebar-collapse\)/,
|
||||
);
|
||||
expect(ruleBody('.sidebar-surface[data-collapsed="true"] .sidebar-row-shift {')).toContain(
|
||||
"transform: translateX(var(--sidebar-row-shift, 0px))",
|
||||
);
|
||||
});
|
||||
|
||||
it("no longer animates any layout property, or any main-thread-only one", () => {
|
||||
// The old `.sidebar-transition` / `.main-transition` pair is gone; nothing
|
||||
// in the sidebar or the main column interpolates geometry any more.
|
||||
expect(css).not.toContain(".sidebar-transition");
|
||||
expect(css).not.toContain(".main-transition");
|
||||
expect(css).not.toMatch(/transition:\s*margin-left/);
|
||||
expect(css).not.toMatch(/transition:\s*clip-path/);
|
||||
// The bespoke sidebar View Transition snapshot rules are gone.
|
||||
expect(css).not.toContain("app-sidebar)");
|
||||
expect(css).not.toContain("data-sidebar-collapse-transition");
|
||||
expect(css).not.toContain(":active-view-transition");
|
||||
});
|
||||
|
||||
it("makes reduced motion an instant state change", () => {
|
||||
const block = reducedMotionBlock();
|
||||
expect(block).toContain("--duration-sidebar-collapse: 0ms");
|
||||
for (const selector of [
|
||||
".sidebar-surface,",
|
||||
".sidebar-inner,",
|
||||
".sidebar-fade,",
|
||||
".sidebar-row-shift,",
|
||||
".sidebar-main-stage,",
|
||||
".impersonation-banner",
|
||||
]) {
|
||||
expect(block).toContain(selector);
|
||||
}
|
||||
});
|
||||
|
||||
it("snaps the impersonation banner's margin instead of animating it", () => {
|
||||
const banner = ruleBody(".impersonation-banner {");
|
||||
expect(banner).toContain("margin-left: var(--app-sidebar-offset)");
|
||||
// Animating margin would put the document back on the per-frame layout path.
|
||||
expect(banner).not.toContain("transition: margin-left");
|
||||
expect(banner).not.toMatch(/transition:[^;]*\b(margin|width|left)\b/);
|
||||
});
|
||||
|
||||
it("carries the impersonation banner's edge with a compensating transform", () => {
|
||||
// The banner renders outside Layout, so it mirrors `.sidebar-main-stage`
|
||||
// off root attributes: hold the previous visual offset with no transition,
|
||||
// then release to zero over the shared sidebar duration/easing.
|
||||
expect(ruleBody(".impersonation-banner {")).not.toContain("transform:");
|
||||
|
||||
const entering = ruleBody(
|
||||
':root[data-sidebar-collapsed="true"]:not([data-sidebar-visual-collapsed="true"])',
|
||||
);
|
||||
expect(entering).toContain("transform: translateX(196px)");
|
||||
expect(entering).toContain("transition: none");
|
||||
|
||||
const leaving = ruleBody(
|
||||
':root:not([data-sidebar-collapsed="true"])[data-sidebar-visual-collapsed="true"]',
|
||||
);
|
||||
expect(leaving).toContain("transform: translateX(-196px)");
|
||||
expect(leaving).toContain("transition: none");
|
||||
|
||||
// The settled rule animates transform on the same tokens as the sidebar.
|
||||
expect(css).toMatch(
|
||||
/\.impersonation-banner \{\s*transition: transform var\(--duration-sidebar-collapse\) var\(--ease-sidebar-collapse\);/,
|
||||
);
|
||||
});
|
||||
|
||||
it("compensates the banner on the same breakpoint that moves its margin", () => {
|
||||
// `html[data-text-scale]` re-bases rem, so 64rem and 1024px diverge at large
|
||||
// text. The compensation must follow `--app-sidebar-offset`'s own query.
|
||||
const nearestQueryBefore = (needle: string) => {
|
||||
const at = css.indexOf(needle);
|
||||
expect(at, `missing: ${needle}`).toBeGreaterThan(-1);
|
||||
const queryAt = css.lastIndexOf("@media", at);
|
||||
return css.slice(queryAt, css.indexOf("{", queryAt)).trim();
|
||||
};
|
||||
|
||||
expect(nearestQueryBefore("--app-sidebar-offset: 64px")).toBe("@media (min-width: 1024px)");
|
||||
expect(
|
||||
nearestQueryBefore(
|
||||
':root[data-sidebar-collapsed="true"]:not([data-sidebar-visual-collapsed="true"])',
|
||||
),
|
||||
).toBe("@media (min-width: 1024px)");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user