From 8c262f801fb21c3975a45658f46b9711b6e2e55f Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Wed, 20 May 2026 22:23:52 +0200 Subject: [PATCH] fix(library): render album/artist + popover through a portal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #75. The z-index fix wasn't enough in practice: the virtualizer rows use `transform: translateY()`, which creates a stacking context, and the popover's `top-full right-0` resolved to "below the entire tile" rather than "below the + button". Some ancestor (likely the page scroller's own transform / will-change set by tanstack-virtual) was clipping or restacking the popover so the following row's avatar/cover still bled through. Switch the popover to a `createPortal(..., document.body)` render path. Each `+` button registers its DOM node in a `Map` ref; the popover takes that node as `anchorEl` and positions itself via `getBoundingClientRect` + scroll/resize listeners. Now anchored right under the trigger button (not below the full tile), and lives at the document root so stacking contexts no longer apply. TrackTable keeps its current in-flow popover — its z-index fix from #74 works because the row layout is a single flex line, not a CSS grid of multiple cards. --- src/components/views/LibraryView.tsx | 83 +++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 3 deletions(-) diff --git a/src/components/views/LibraryView.tsx b/src/components/views/LibraryView.tsx index 33f3357..c2805d3 100644 --- a/src/components/views/LibraryView.tsx +++ b/src/components/views/LibraryView.tsx @@ -5,6 +5,7 @@ import { useRef, useState, } from "react"; +import { createPortal } from "react-dom"; import { useVirtualizer } from "@tanstack/react-virtual"; import { Music2, @@ -1325,6 +1326,15 @@ interface AddToPlaylistPopoverProps { * "+ to playlist" action is a bulk add with no symmetric remove. */ memberPlaylistIds?: ReadonlySet; + /** + * Trigger element the popover anchors to. When provided, the popover + * is rendered through a portal at `document.body` and positioned via + * `getBoundingClientRect`, escaping every ancestor stacking context + * (virtualizer rows use `transform`, which traps `z-index` inside). + * Required for album / artist grids where the popover would otherwise + * paint under the row below it. + */ + anchorEl?: HTMLElement | null; } /** @@ -1332,7 +1342,14 @@ interface AddToPlaylistPopoverProps { * the active profile (resolved color tile + name) plus a "create new" * shortcut at the bottom. Picking a row calls `onPick(playlistId)`. * - * Stops `onDoubleClick` from bubbling to the parent `
  • ` so clicking a + * When `anchorEl` is supplied, the popover is rendered via React portal + * to `document.body` and positioned absolutely against the anchor's + * client rect. Without it, the popover falls back to absolute positioning + * inside its parent — only safe where the parent isn't sitting inside a + * `transform`-clipped stacking context (TrackTable rows qualify; album / + * artist grids don't). + * + * Stops `onDoubleClick` from bubbling to the parent so clicking a * playlist doesn't accidentally start playback of the row underneath. */ function AddToPlaylistPopover({ @@ -1341,13 +1358,54 @@ function AddToPlaylistPopover({ onCreate, t, memberPlaylistIds, + anchorEl, }: AddToPlaylistPopoverProps) { - return ( + // Portal mode: track the anchor's viewport rect so the popover follows + // it on scroll / resize / virtualization recycling. `null` rect = first + // render before the layout effect runs; we keep the popover invisible + // until we know where it goes so it never flashes at (0,0). + const POPOVER_WIDTH = 224; // matches `w-56` + const [rect, setRect] = useState(null); + useLayoutEffect(() => { + if (!anchorEl) return; + const update = () => setRect(anchorEl.getBoundingClientRect()); + update(); + const ro = new ResizeObserver(update); + ro.observe(anchorEl); + window.addEventListener("scroll", update, true); + window.addEventListener("resize", update); + return () => { + ro.disconnect(); + window.removeEventListener("scroll", update, true); + window.removeEventListener("resize", update); + }; + }, [anchorEl]); + + const inner = (
    e.stopPropagation()} - className="absolute top-full right-0 mt-1 z-50 w-56 rounded-xl border border-zinc-200 bg-white shadow-lg dark:border-zinc-700 dark:bg-surface-dark-elevated dark:shadow-black/40 overflow-hidden animate-fade-in" + style={ + anchorEl + ? rect + ? { + position: "fixed", + // Anchor the right edge to the trigger's right edge, + // matching the in-flow `right-0` behaviour. Drop 4 px + // below the trigger to match `mt-1`. + top: rect.bottom + 4, + left: rect.right - POPOVER_WIDTH, + width: POPOVER_WIDTH, + } + : { position: "fixed", visibility: "hidden" } + : undefined + } + className={`${ + anchorEl + ? "z-100" + : "absolute top-full right-0 mt-1 z-50 w-56" + } rounded-xl border border-zinc-200 bg-white shadow-lg dark:border-zinc-700 dark:bg-surface-dark-elevated dark:shadow-black/40 overflow-hidden animate-fade-in`} >
    {t("trackActions.addToPlaylist")} @@ -1409,6 +1467,7 @@ function AddToPlaylistPopover({
    ); + return anchorEl ? createPortal(inner, document.body) : inner; } interface AlbumGridProps { @@ -1435,6 +1494,10 @@ function AlbumGrid({ "use no memo"; const unknown = t("library.table.unknown"); const [openMenuAlbumId, setOpenMenuAlbumId] = useState(null); + // Map album.id → the `+` button DOM node. The popover uses the live + // node to compute its portal position via `getBoundingClientRect`, + // sidestepping every ancestor stacking context. + const triggerRefs = useRef>(new Map()); const [contextMenu, setContextMenu] = useState<{ albumId: number; x: number; @@ -1576,6 +1639,10 @@ function AlbumGrid({