fix(studio): stop popovers and tooltips clipping at panel edges (#2890)

The Renders tab format popover rendered as an in-flow absolute panel inside
the right panel, which is overflow-hidden, so it was sliced at the panel
edge. Portal it to the body and position it with the shared floating-panel
helper instead.

The ui/Tooltip bubble clamped only its centre point to the viewport, so a
wide bubble near an edge still hung off-screen (the timeline Selection tool
tooltip lost 32px on the left). Clamp with the measured bubble width.
This commit is contained in:
Miguel Ángel
2026-07-30 02:27:48 +02:00
committed by GitHub
parent cef3b86c95
commit f81ac74572
4 changed files with 114 additions and 31 deletions
@@ -1,5 +1,24 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { resolveFloatingPanelPosition } from "./floatingPanel"; import { clampCentredLeft, resolveFloatingPanelPosition } from "./floatingPanel";
describe("clampCentredLeft", () => {
it("leaves a bubble that already fits alone", () => {
expect(clampCentredLeft(400, 104, 800, 8)).toBe(400);
});
it("pushes a bubble whose left half would leave the viewport", () => {
// Trigger centred at x=20 with a 104px bubble would render at left=-32.
expect(clampCentredLeft(20, 104, 800, 8)).toBe(60);
});
it("pushes a bubble whose right half would leave the viewport", () => {
expect(clampCentredLeft(790, 104, 800, 8)).toBe(740);
});
it("keeps the left edge visible when the bubble is wider than the viewport", () => {
expect(clampCentredLeft(10, 900, 800, 8)).toBe(458);
});
});
describe("resolveFloatingPanelPosition", () => { describe("resolveFloatingPanelPosition", () => {
it("places the panel below the anchor when there is space", () => { it("places the panel below the anchor when there is space", () => {
@@ -22,6 +22,21 @@ function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value)); return Math.max(min, Math.min(max, value));
} }
/**
* Clamp the centre point of a centred bubble so the whole bubble stays in the
* viewport: clamping the centre alone lets a wide bubble hang off the edge.
*/
export function clampCentredLeft(
centreX: number,
bubbleWidth: number,
viewportWidth: number,
margin: number,
): number {
const half = bubbleWidth / 2;
const min = half + margin;
return clamp(centreX, min, Math.max(min, viewportWidth - half - margin));
}
export function resolveFloatingPanelPosition( export function resolveFloatingPanelPosition(
anchor: FloatingRect, anchor: FloatingRect,
viewport: FloatingSize, viewport: FloatingSize,
@@ -1,7 +1,9 @@
import { memo, useState, useRef, useEffect, useId } from "react"; import { memo, useState, useRef, useEffect, useLayoutEffect, useId } from "react";
import { createPortal } from "react-dom";
import { CANVAS_DIMENSIONS } from "@hyperframes/parsers"; import { CANVAS_DIMENSIONS } from "@hyperframes/parsers";
import { RenderQueueItem } from "./RenderQueueItem"; import { RenderQueueItem } from "./RenderQueueItem";
import { Button } from "../ui/Button"; import { Button } from "../ui/Button";
import { resolveFloatingPanelPosition, type FloatingPosition } from "../editor/floatingPanel";
import type { RenderJob, ResolutionPreset } from "./useRenderQueue"; import type { RenderJob, ResolutionPreset } from "./useRenderQueue";
import { getPersistedRenderSettings, persistRenderSettings } from "./renderSettings"; import { getPersistedRenderSettings, persistRenderSettings } from "./renderSettings";
import { trackStudioEvent } from "../../utils/studioTelemetry"; import { trackStudioEvent } from "../../utils/studioTelemetry";
@@ -132,12 +134,20 @@ const FORMAT_INFO: Record<"mp4" | "webm" | "mov", { label: string; desc: string
}, },
}; };
// Estimated, like COLOR_PICKER_SIZE in propertyPanelColor: only the flip
// decision uses the height, and the clamp keeps the panel on screen either way.
const FORMAT_PANEL_SIZE = { width: 208, height: 150 };
// Rich format guidance in a keyboard-reachable disclosure: the trigger is a // Rich format guidance in a keyboard-reachable disclosure: the trigger is a
// real button (focusable, labelled), the panel is tied to it via // real button (focusable, labelled), the panel is tied to it via
// aria-describedby, and Escape dismisses (WCAG 1.4.13). Content is too rich // aria-describedby, and Escape dismisses (WCAG 1.4.13). Content is too rich
// for the one-line ui/Tooltip primitive, so this stays a local popover. // for the one-line ui/Tooltip primitive, so this stays a local popover.
// It renders in a portal because the right panel is overflow-hidden: an
// in-flow absolute panel gets clipped at the panel edge.
function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) { function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [position, setPosition] = useState<FloatingPosition | null>(null);
const triggerRef = useRef<HTMLDivElement>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined); const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const panelId = useId(); const panelId = useId();
@@ -151,6 +161,22 @@ function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) {
useEffect(() => () => clearTimeout(timeoutRef.current), []); useEffect(() => () => clearTimeout(timeoutRef.current), []);
// Positioned once on open, so it does not follow panel scroll. The popover
// is hover-lived; add a scroll listener only if that ever shows up.
useLayoutEffect(() => {
if (!open) return;
const el = triggerRef.current;
if (!el) return;
setPosition(
resolveFloatingPanelPosition(
el.getBoundingClientRect(),
{ width: window.innerWidth, height: window.innerHeight },
FORMAT_PANEL_SIZE,
{ offset: 6 },
),
);
}, [open]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
@@ -163,7 +189,7 @@ function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) {
const info = FORMAT_INFO[format]; const info = FORMAT_INFO[format];
return ( return (
<div className="relative" onPointerEnter={show} onPointerLeave={hide}> <div ref={triggerRef} className="relative" onPointerEnter={show} onPointerLeave={hide}>
<button <button
type="button" type="button"
aria-label="About video formats" aria-label="About video formats"
@@ -190,11 +216,15 @@ function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) {
<line x1="12" y1="17" x2="12.01" y2="17" /> <line x1="12" y1="17" x2="12.01" y2="17" />
</svg> </svg>
</button> </button>
{open && ( {open &&
createPortal(
<div <div
id={panelId} id={panelId}
role="tooltip" role="tooltip"
className="absolute top-full right-0 mt-1.5 w-52 p-2 rounded bg-panel-input border border-neutral-700 shadow-lg z-50" onPointerEnter={show}
onPointerLeave={hide}
className="fixed w-52 p-2 rounded bg-panel-input border border-neutral-700 shadow-lg z-[200]"
style={{ left: position?.left ?? -9999, top: position?.top ?? -9999 }}
> >
<p className="text-[10px] font-semibold text-panel-text-1 mb-0.5">{info.label}</p> <p className="text-[10px] font-semibold text-panel-text-1 mb-0.5">{info.label}</p>
<p className="text-[9px] text-panel-text-3 leading-tight">{info.desc}</p> <p className="text-[9px] text-panel-text-3 leading-tight">{info.desc}</p>
@@ -209,7 +239,8 @@ function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) {
</p> </p>
))} ))}
</div> </div>
</div> </div>,
document.body,
)} )}
</div> </div>
); );
+25 -7
View File
@@ -1,5 +1,14 @@
import { useState, useRef, useCallback, useEffect, useId, type ReactNode } from "react"; import {
useState,
useRef,
useCallback,
useEffect,
useLayoutEffect,
useId,
type ReactNode,
} from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { clampCentredLeft } from "../editor/floatingPanel";
interface TooltipProps { interface TooltipProps {
label: string; label: string;
@@ -19,6 +28,8 @@ export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipP
const [resolvedSide, setResolvedSide] = useState<"top" | "bottom">(side); const [resolvedSide, setResolvedSide] = useState<"top" | "bottom">(side);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const triggerRef = useRef<HTMLSpanElement>(null); const triggerRef = useRef<HTMLSpanElement>(null);
const bubbleRef = useRef<HTMLDivElement>(null);
const [bubbleWidth, setBubbleWidth] = useState(0);
// WCAG 4.1.2: programmatically associate the bubble with its trigger. // WCAG 4.1.2: programmatically associate the bubble with its trigger.
const tooltipId = useId(); const tooltipId = useId();
@@ -40,13 +51,11 @@ export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipP
) { ) {
nextSide = "top"; nextSide = "top";
} }
const x = Math.min(
Math.max(rect.left + rect.width / 2, VIEWPORT_MARGIN),
window.innerWidth - VIEWPORT_MARGIN,
);
setResolvedSide(nextSide); setResolvedSide(nextSide);
setPos({ setPos({
x, // Raw trigger centre; clamped to the viewport at render, once the
// bubble's own width is known (see clampedX).
x: rect.left + rect.width / 2,
y: nextSide === "top" ? rect.top - 6 : rect.bottom + 6, y: nextSide === "top" ? rect.top - 6 : rect.bottom + 6,
}); });
setVisible(true); setVisible(true);
@@ -61,6 +70,12 @@ export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipP
setVisible(false); setVisible(false);
}, []); }, []);
// Measure before paint so a wide bubble near a viewport edge is clamped in
// the same commit it appears in (no visible jump).
useLayoutEffect(() => {
setBubbleWidth(visible ? (bubbleRef.current?.offsetWidth ?? 0) : 0);
}, [visible, label]);
// WCAG 1.4.13: tooltip content must be dismissible with Escape. // WCAG 1.4.13: tooltip content must be dismissible with Escape.
useEffect(() => { useEffect(() => {
if (!visible) return; if (!visible) return;
@@ -71,6 +86,8 @@ export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipP
return () => document.removeEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown);
}, [visible, hide]); }, [visible, hide]);
const clampedX = clampCentredLeft(pos.x, bubbleWidth, window.innerWidth, VIEWPORT_MARGIN);
return ( return (
<> <>
<span <span
@@ -89,12 +106,13 @@ export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipP
<div <div
className="fixed z-[200] pointer-events-none" className="fixed z-[200] pointer-events-none"
style={{ style={{
left: pos.x, left: clampedX,
top: pos.y, top: pos.y,
transform: resolvedSide === "top" ? "translate(-50%, -100%)" : "translate(-50%, 0)", transform: resolvedSide === "top" ? "translate(-50%, -100%)" : "translate(-50%, 0)",
}} }}
> >
<div <div
ref={bubbleRef}
role="tooltip" role="tooltip"
id={tooltipId} id={tooltipId}
className="px-2 py-1 rounded-md bg-neutral-800 border border-neutral-700/50 text-[10px] font-medium text-neutral-200 whitespace-nowrap shadow-lg" className="px-2 py-1 rounded-md bg-neutral-800 border border-neutral-700/50 text-[10px] font-medium text-neutral-200 whitespace-nowrap shadow-lg"