Files
CapaKraken/apps/web/src/hooks/useViewportPopover.ts
T
Hartmut 8d9e26872b fix(timeline): stabilize popovers on internal scroll + expand test coverage
B-1: useViewportPopover — ignoreScrollContainers option; scroll events
originating inside the timeline canvas no longer close point-anchor popovers
B-2: AllocationPopover, DemandPopover, NewAllocationPopover — thread
scrollContainerRef through so horizontal timeline scroll is ignored
B-3: AllocationPopover — staleTime 0 so SSE reconnect triggers immediate refetch
B-4: useViewportPopover.test.ts — 6 new tests (scroll close, ignore container,
resize close, style clamping)
B-5: AllocationPopover.test.tsx — loading state + happy-path tests added

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-02 20:49:08 +02:00

242 lines
6.4 KiB
TypeScript

import React, { useEffect, useRef, useState, type CSSProperties } from "react";
type PopoverAnchor =
| { kind: "point"; x: number; y: number }
| { kind: "element"; element: HTMLElement };
type PopoverSide = "bottom" | "right";
type PopoverAlign = "start" | "end" | "center";
interface UseViewportPopoverOptions {
anchor: PopoverAnchor;
width: number;
estimatedHeight: number;
onClose: () => void;
side?: PopoverSide;
align?: PopoverAlign;
offset?: number;
viewportPadding?: number;
ignoreElements?: Array<HTMLElement | null>;
ignoreSelectors?: string[];
ignoreScrollContainers?: React.RefObject<HTMLElement | null>[];
zIndex?: number;
}
export function useViewportPopover({
anchor,
width,
estimatedHeight,
onClose,
side = "bottom",
align = "start",
offset = 8,
viewportPadding = 16,
ignoreElements = [],
ignoreSelectors = [],
ignoreScrollContainers,
zIndex = 9998,
}: UseViewportPopoverOptions) {
const ref = useRef<HTMLDivElement>(null);
const frameRef = useRef<number | null>(null);
const computeStyle = (): CSSProperties => {
if (typeof window === "undefined") {
return {
position: "fixed",
left: viewportPadding,
top: viewportPadding,
width,
zIndex,
};
}
let left = 0;
let top = 0;
if (anchor.kind === "element") {
const rect = anchor.element.getBoundingClientRect();
if (side === "right") {
left = rect.right + offset;
if (align === "end") {
top = rect.bottom - estimatedHeight;
} else if (align === "center") {
top = rect.top + rect.height / 2 - estimatedHeight / 2;
} else {
top = rect.top;
}
} else {
left = rect.left;
if (align === "end") {
left = rect.right - width;
} else if (align === "center") {
left = rect.left + rect.width / 2 - width / 2;
}
top = rect.bottom + offset;
}
} else {
left = anchor.x;
top = anchor.y + offset;
if (align === "end") {
left = anchor.x - width;
} else if (align === "center") {
left = anchor.x - width / 2;
}
}
const measuredWidth = ref.current?.offsetWidth ?? width;
const measuredHeight = ref.current?.offsetHeight ?? estimatedHeight;
const maxLeft = Math.max(viewportPadding, window.innerWidth - measuredWidth - viewportPadding);
const maxTop = Math.max(viewportPadding, window.innerHeight - measuredHeight - viewportPadding);
return {
position: "fixed",
left: Math.min(Math.max(left, viewportPadding), maxLeft),
top: Math.min(Math.max(top, viewportPadding), maxTop),
width,
zIndex,
};
};
const [style, setStyle] = useState<CSSProperties>(() => computeStyle());
useEffect(() => {
function handlePointerDown(event: PointerEvent) {
const target = event.target;
if (!(target instanceof Node)) {
return;
}
if (ref.current?.contains(target)) {
return;
}
if (ignoreElements.some((element) => element?.contains(target))) {
return;
}
if (
target instanceof Element
&& ignoreSelectors.some((selector) => target.closest(selector) !== null)
) {
return;
}
onClose();
}
function handleEscape(event: KeyboardEvent) {
if (event.key === "Escape") {
onClose();
}
}
document.addEventListener("pointerdown", handlePointerDown, true);
window.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("pointerdown", handlePointerDown, true);
window.removeEventListener("keydown", handleEscape);
};
}, [ignoreElements, ignoreSelectors, onClose]);
useEffect(() => {
setStyle(computeStyle());
}, [align, anchor, estimatedHeight, offset, side, viewportPadding, width, zIndex]);
useEffect(() => {
const element = ref.current;
if (!element || typeof ResizeObserver === "undefined") {
return;
}
const observer = new ResizeObserver(() => {
setStyle(computeStyle());
});
observer.observe(element);
return () => {
observer.disconnect();
};
}, [align, anchor, estimatedHeight, offset, side, viewportPadding, width, zIndex]);
useEffect(() => {
const closeOnViewportChange = anchor.kind === "point";
function cancelScheduledFrame() {
if (frameRef.current === null) return;
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
function updateOrClose() {
if (anchor.kind === "element" && !anchor.element.isConnected) {
onClose();
return;
}
setStyle(computeStyle());
}
function scheduleUpdate(reason: "scroll" | "resize") {
if (frameRef.current !== null) return;
frameRef.current = requestAnimationFrame(() => {
frameRef.current = null;
updateOrClose();
});
}
updateOrClose();
const handleScroll = (event: Event) => {
if (closeOnViewportChange) {
const scrollTarget = (event as Event).target;
if (
ignoreScrollContainers?.some(
(r) =>
r.current != null &&
scrollTarget instanceof Node &&
r.current.contains(scrollTarget),
)
) {
return;
}
cancelScheduledFrame();
onClose();
return;
}
scheduleUpdate("scroll");
};
const handleResize = () => {
if (closeOnViewportChange) {
cancelScheduledFrame();
onClose();
return;
}
scheduleUpdate("resize");
};
window.addEventListener("scroll", handleScroll, true);
window.addEventListener("resize", handleResize, { passive: true });
window.visualViewport?.addEventListener("resize", handleResize, { passive: true });
window.visualViewport?.addEventListener("scroll", handleScroll, { passive: true });
return () => {
cancelScheduledFrame();
window.removeEventListener("scroll", handleScroll, true);
window.removeEventListener("resize", handleResize);
window.visualViewport?.removeEventListener("resize", handleResize);
window.visualViewport?.removeEventListener("scroll", handleScroll);
};
}, [
align,
anchor,
estimatedHeight,
ignoreScrollContainers,
offset,
onClose,
side,
viewportPadding,
width,
zIndex,
]);
return { ref, style };
}