8d9e26872b
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>
242 lines
6.4 KiB
TypeScript
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 };
|
|
}
|