feat(player): polish the description popover (opaque, portaled, opens upward)

The popover bled through (it used the translucent glass surface), was clipped by
the modal card's overflow, and ran off the bottom of the viewport. Make it an
opaque surface, render it through a portal to <body> with fixed positioning so
nothing clips it, and anchor it above the title so it grows upward where there's
room. Add a small hover grace so the pointer can travel title → popover.
This commit is contained in:
npeter83 2026-06-12 17:39:08 +02:00
parent 1616bcc223
commit 2a5660e01e

View file

@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Check, CheckCheck, X } from "lucide-react"; import { Check, CheckCheck, X } from "lucide-react";
import { api, type Video } from "../lib/api"; import { api, type Video } from "../lib/api";
@ -71,8 +72,29 @@ export default function PlayerModal({
onState(video.id, next); onState(video.id, next);
}; };
// Lazy description (fetched only when the title is hovered). // Lazy description (fetched only when the title is hovered). The popover is
// portaled to <body> with fixed positioning so the modal card's overflow-y-auto
// can't clip it. A small close grace lets the mouse travel title → popover.
const [showDesc, setShowDesc] = useState(false); const [showDesc, setShowDesc] = useState(false);
// `bottom` anchors the popover just above the title so it grows upward (over the
// player) instead of downward off-screen / behind the OS taskbar.
const [descRect, setDescRect] = useState<{ left: number; bottom: number; width: number } | null>(
null
);
const titleRef = useRef<HTMLDivElement | null>(null);
const closeTimer = useRef<number | undefined>(undefined);
const openDesc = () => {
window.clearTimeout(closeTimer.current);
const el = titleRef.current;
if (el) {
const r = el.getBoundingClientRect();
setDescRect({ left: r.left, bottom: window.innerHeight - r.top + 8, width: r.width });
}
setShowDesc(true);
};
const scheduleCloseDesc = () => {
closeTimer.current = window.setTimeout(() => setShowDesc(false), 150);
};
const detail = useQuery({ const detail = useQuery({
queryKey: ["video-detail", video.id], queryKey: ["video-detail", video.id],
queryFn: () => api.videoDetail(video.id), queryFn: () => api.videoDetail(video.id),
@ -91,6 +113,7 @@ export default function PlayerModal({
return () => { return () => {
window.removeEventListener("keydown", onKey); window.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow; document.body.style.overflow = prevOverflow;
window.clearTimeout(closeTimer.current);
}; };
}, [onClose]); }, [onClose]);
@ -185,15 +208,28 @@ export default function PlayerModal({
{/* Title row — title (with hover description) on the left, Close on the right. */} {/* Title row — title (with hover description) on the left, Close on the right. */}
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div <div
className="relative min-w-0 flex-1" ref={titleRef}
onMouseEnter={() => setShowDesc(true)} className="min-w-0 flex-1"
onMouseLeave={() => setShowDesc(false)} onMouseEnter={openDesc}
onMouseLeave={scheduleCloseDesc}
> >
<h2 className="text-lg font-semibold leading-snug cursor-default"> <h2 className="text-lg font-semibold leading-snug cursor-default">
{video.title} {video.title}
</h2> </h2>
{showDesc && ( </div>
<div className="absolute left-0 top-full mt-2 z-10 w-full max-w-2xl glass-card rounded-xl shadow-2xl p-4"> {showDesc &&
descRect &&
createPortal(
<div
className="fixed z-[60] bg-surface border border-border rounded-xl shadow-2xl p-4"
style={{
left: descRect.left,
bottom: descRect.bottom,
width: descRect.width,
}}
onMouseEnter={openDesc}
onMouseLeave={scheduleCloseDesc}
>
<div className="text-xs uppercase tracking-wide text-muted mb-2"> <div className="text-xs uppercase tracking-wide text-muted mb-2">
Description Description
</div> </div>
@ -206,9 +242,9 @@ export default function PlayerModal({
) : ( ) : (
<div className="text-sm text-muted">No description.</div> <div className="text-sm text-muted">No description.</div>
)} )}
</div> </div>,
document.body
)} )}
</div>
<button <button
onClick={onClose} onClick={onClose}
title="Close (Esc)" title="Close (Esc)"