Skip to content

Commit f81ac74

Browse files
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.
1 parent cef3b86 commit f81ac74

4 files changed

Lines changed: 114 additions & 31 deletions

File tree

packages/studio/src/components/editor/floatingPanel.test.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,24 @@
11
import { describe, expect, it } from "vitest";
2-
import { resolveFloatingPanelPosition } from "./floatingPanel";
2+
import { clampCentredLeft, resolveFloatingPanelPosition } from "./floatingPanel";
3+
4+
describe("clampCentredLeft", () => {
5+
it("leaves a bubble that already fits alone", () => {
6+
expect(clampCentredLeft(400, 104, 800, 8)).toBe(400);
7+
});
8+
9+
it("pushes a bubble whose left half would leave the viewport", () => {
10+
// Trigger centred at x=20 with a 104px bubble would render at left=-32.
11+
expect(clampCentredLeft(20, 104, 800, 8)).toBe(60);
12+
});
13+
14+
it("pushes a bubble whose right half would leave the viewport", () => {
15+
expect(clampCentredLeft(790, 104, 800, 8)).toBe(740);
16+
});
17+
18+
it("keeps the left edge visible when the bubble is wider than the viewport", () => {
19+
expect(clampCentredLeft(10, 900, 800, 8)).toBe(458);
20+
});
21+
});
322

423
describe("resolveFloatingPanelPosition", () => {
524
it("places the panel below the anchor when there is space", () => {

packages/studio/src/components/editor/floatingPanel.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,21 @@ function clamp(value: number, min: number, max: number): number {
2222
return Math.max(min, Math.min(max, value));
2323
}
2424

25+
/**
26+
* Clamp the centre point of a centred bubble so the whole bubble stays in the
27+
* viewport: clamping the centre alone lets a wide bubble hang off the edge.
28+
*/
29+
export function clampCentredLeft(
30+
centreX: number,
31+
bubbleWidth: number,
32+
viewportWidth: number,
33+
margin: number,
34+
): number {
35+
const half = bubbleWidth / 2;
36+
const min = half + margin;
37+
return clamp(centreX, min, Math.max(min, viewportWidth - half - margin));
38+
}
39+
2540
export function resolveFloatingPanelPosition(
2641
anchor: FloatingRect,
2742
viewport: FloatingSize,

packages/studio/src/components/renders/RenderQueue.tsx

Lines changed: 54 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
import { memo, useState, useRef, useEffect, useId } from "react";
1+
import { memo, useState, useRef, useEffect, useLayoutEffect, useId } from "react";
2+
import { createPortal } from "react-dom";
23
import { CANVAS_DIMENSIONS } from "@hyperframes/parsers";
34
import { RenderQueueItem } from "./RenderQueueItem";
45
import { Button } from "../ui/Button";
6+
import { resolveFloatingPanelPosition, type FloatingPosition } from "../editor/floatingPanel";
57
import type { RenderJob, ResolutionPreset } from "./useRenderQueue";
68
import { getPersistedRenderSettings, persistRenderSettings } from "./renderSettings";
79
import { trackStudioEvent } from "../../utils/studioTelemetry";
@@ -132,12 +134,20 @@ const FORMAT_INFO: Record<"mp4" | "webm" | "mov", { label: string; desc: string
132134
},
133135
};
134136

137+
// Estimated, like COLOR_PICKER_SIZE in propertyPanelColor: only the flip
138+
// decision uses the height, and the clamp keeps the panel on screen either way.
139+
const FORMAT_PANEL_SIZE = { width: 208, height: 150 };
140+
135141
// Rich format guidance in a keyboard-reachable disclosure: the trigger is a
136142
// real button (focusable, labelled), the panel is tied to it via
137143
// aria-describedby, and Escape dismisses (WCAG 1.4.13). Content is too rich
138144
// for the one-line ui/Tooltip primitive, so this stays a local popover.
145+
// It renders in a portal because the right panel is overflow-hidden: an
146+
// in-flow absolute panel gets clipped at the panel edge.
139147
function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) {
140148
const [open, setOpen] = useState(false);
149+
const [position, setPosition] = useState<FloatingPosition | null>(null);
150+
const triggerRef = useRef<HTMLDivElement>(null);
141151
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
142152
const panelId = useId();
143153

@@ -151,6 +161,22 @@ function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) {
151161

152162
useEffect(() => () => clearTimeout(timeoutRef.current), []);
153163

164+
// Positioned once on open, so it does not follow panel scroll. The popover
165+
// is hover-lived; add a scroll listener only if that ever shows up.
166+
useLayoutEffect(() => {
167+
if (!open) return;
168+
const el = triggerRef.current;
169+
if (!el) return;
170+
setPosition(
171+
resolveFloatingPanelPosition(
172+
el.getBoundingClientRect(),
173+
{ width: window.innerWidth, height: window.innerHeight },
174+
FORMAT_PANEL_SIZE,
175+
{ offset: 6 },
176+
),
177+
);
178+
}, [open]);
179+
154180
useEffect(() => {
155181
if (!open) return;
156182
const onKeyDown = (e: KeyboardEvent) => {
@@ -163,7 +189,7 @@ function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) {
163189
const info = FORMAT_INFO[format];
164190

165191
return (
166-
<div className="relative" onPointerEnter={show} onPointerLeave={hide}>
192+
<div ref={triggerRef} className="relative" onPointerEnter={show} onPointerLeave={hide}>
167193
<button
168194
type="button"
169195
aria-label="About video formats"
@@ -190,27 +216,32 @@ function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) {
190216
<line x1="12" y1="17" x2="12.01" y2="17" />
191217
</svg>
192218
</button>
193-
{open && (
194-
<div
195-
id={panelId}
196-
role="tooltip"
197-
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"
198-
>
199-
<p className="text-[10px] font-semibold text-panel-text-1 mb-0.5">{info.label}</p>
200-
<p className="text-[9px] text-panel-text-3 leading-tight">{info.desc}</p>
201-
<div className="mt-1.5 pt-1.5 border-t border-neutral-800">
202-
{(["mp4", "mov", "webm"] as const)
203-
.filter((f) => f !== format)
204-
.map((f) => (
205-
<p key={f} className="text-[9px] text-panel-text-4 leading-relaxed">
206-
<span className="text-panel-text-3 font-medium">{FORMAT_INFO[f].label}</span>
207-
{" — "}
208-
{FORMAT_INFO[f].desc}
209-
</p>
210-
))}
211-
</div>
212-
</div>
213-
)}
219+
{open &&
220+
createPortal(
221+
<div
222+
id={panelId}
223+
role="tooltip"
224+
onPointerEnter={show}
225+
onPointerLeave={hide}
226+
className="fixed w-52 p-2 rounded bg-panel-input border border-neutral-700 shadow-lg z-[200]"
227+
style={{ left: position?.left ?? -9999, top: position?.top ?? -9999 }}
228+
>
229+
<p className="text-[10px] font-semibold text-panel-text-1 mb-0.5">{info.label}</p>
230+
<p className="text-[9px] text-panel-text-3 leading-tight">{info.desc}</p>
231+
<div className="mt-1.5 pt-1.5 border-t border-neutral-800">
232+
{(["mp4", "mov", "webm"] as const)
233+
.filter((f) => f !== format)
234+
.map((f) => (
235+
<p key={f} className="text-[9px] text-panel-text-4 leading-relaxed">
236+
<span className="text-panel-text-3 font-medium">{FORMAT_INFO[f].label}</span>
237+
{" — "}
238+
{FORMAT_INFO[f].desc}
239+
</p>
240+
))}
241+
</div>
242+
</div>,
243+
document.body,
244+
)}
214245
</div>
215246
);
216247
}

packages/studio/src/components/ui/Tooltip.tsx

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
1-
import { useState, useRef, useCallback, useEffect, useId, type ReactNode } from "react";
1+
import {
2+
useState,
3+
useRef,
4+
useCallback,
5+
useEffect,
6+
useLayoutEffect,
7+
useId,
8+
type ReactNode,
9+
} from "react";
210
import { createPortal } from "react-dom";
11+
import { clampCentredLeft } from "../editor/floatingPanel";
312

413
interface TooltipProps {
514
label: string;
@@ -19,6 +28,8 @@ export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipP
1928
const [resolvedSide, setResolvedSide] = useState<"top" | "bottom">(side);
2029
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
2130
const triggerRef = useRef<HTMLSpanElement>(null);
31+
const bubbleRef = useRef<HTMLDivElement>(null);
32+
const [bubbleWidth, setBubbleWidth] = useState(0);
2233
// WCAG 4.1.2: programmatically associate the bubble with its trigger.
2334
const tooltipId = useId();
2435

@@ -40,13 +51,11 @@ export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipP
4051
) {
4152
nextSide = "top";
4253
}
43-
const x = Math.min(
44-
Math.max(rect.left + rect.width / 2, VIEWPORT_MARGIN),
45-
window.innerWidth - VIEWPORT_MARGIN,
46-
);
4754
setResolvedSide(nextSide);
4855
setPos({
49-
x,
56+
// Raw trigger centre; clamped to the viewport at render, once the
57+
// bubble's own width is known (see clampedX).
58+
x: rect.left + rect.width / 2,
5059
y: nextSide === "top" ? rect.top - 6 : rect.bottom + 6,
5160
});
5261
setVisible(true);
@@ -61,6 +70,12 @@ export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipP
6170
setVisible(false);
6271
}, []);
6372

73+
// Measure before paint so a wide bubble near a viewport edge is clamped in
74+
// the same commit it appears in (no visible jump).
75+
useLayoutEffect(() => {
76+
setBubbleWidth(visible ? (bubbleRef.current?.offsetWidth ?? 0) : 0);
77+
}, [visible, label]);
78+
6479
// WCAG 1.4.13: tooltip content must be dismissible with Escape.
6580
useEffect(() => {
6681
if (!visible) return;
@@ -71,6 +86,8 @@ export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipP
7186
return () => document.removeEventListener("keydown", onKeyDown);
7287
}, [visible, hide]);
7388

89+
const clampedX = clampCentredLeft(pos.x, bubbleWidth, window.innerWidth, VIEWPORT_MARGIN);
90+
7491
return (
7592
<>
7693
<span
@@ -89,12 +106,13 @@ export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipP
89106
<div
90107
className="fixed z-[200] pointer-events-none"
91108
style={{
92-
left: pos.x,
109+
left: clampedX,
93110
top: pos.y,
94111
transform: resolvedSide === "top" ? "translate(-50%, -100%)" : "translate(-50%, 0)",
95112
}}
96113
>
97114
<div
115+
ref={bubbleRef}
98116
role="tooltip"
99117
id={tooltipId}
100118
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"

0 commit comments

Comments
 (0)