omniAI / frontend /src /components /FloatingPanel.tsx
hasimjaneef's picture
Publish validated findings before turn end and trace pipeline latency
79e11ca verified
Raw History Blame Contribute Delete
8.27 kB
import {
useEffect,
useRef,
useState,
type CSSProperties,
type KeyboardEvent,
type PointerEvent,
type ReactNode,
} from "react";
import {
clampPanelGeometry,
defaultPanelGeometry,
movePanelGeometry,
resizePanelGeometry,
type PanelGeometry,
type PanelMinimums,
type PanelPlacement,
} from "../panelGeometry";
import { Icon } from "./Icon";
interface FloatingPanelProps {
panelId: string;
title: string;
eyebrow: string;
subtitle: string;
placement: PanelPlacement;
active: boolean;
analysisActive?: boolean;
open: boolean;
embeddedWhenInactive?: boolean;
stopDisabled?: boolean;
minimumWidth: number;
minimumHeight: number;
children: ReactNode;
onClose: () => void;
onStop: () => void;
}
interface PointerInteraction {
kind: "move" | "resize";
pointerId: number;
clientX: number;
clientY: number;
geometry: PanelGeometry;
}
let highestPanelLayer = 80;
function viewportSize() {
return {
width: typeof window === "undefined" ? 1280 : window.innerWidth,
height: typeof window === "undefined" ? 800 : window.innerHeight,
};
}
function keyboardDelta(event: KeyboardEvent<HTMLElement>): { x: number; y: number } | null {
const amount = event.shiftKey ? 24 : 8;
if (event.key === "ArrowLeft") return { x: -amount, y: 0 };
if (event.key === "ArrowRight") return { x: amount, y: 0 };
if (event.key === "ArrowUp") return { x: 0, y: -amount };
if (event.key === "ArrowDown") return { x: 0, y: amount };
return null;
}
export function FloatingPanel({
panelId,
title,
eyebrow,
subtitle,
placement,
active,
analysisActive = active,
open,
embeddedWhenInactive = false,
stopDisabled = false,
minimumWidth,
minimumHeight,
children,
onClose,
onStop,
}: FloatingPanelProps) {
const minimums: PanelMinimums = { width: minimumWidth, height: minimumHeight };
const [geometry, setGeometry] = useState(() =>
defaultPanelGeometry(placement, viewportSize(), minimums),
);
const [layer, setLayer] = useState(() => ++highestPanelLayer);
const interactionRef = useRef<PointerInteraction | null>(null);
const wasActiveRef = useRef(false);
const bringForward = () => setLayer(++highestPanelLayer);
useEffect(() => {
if (active && !wasActiveRef.current) {
setGeometry(defaultPanelGeometry(placement, viewportSize(), minimums));
bringForward();
}
wasActiveRef.current = active;
}, [active, minimumHeight, minimumWidth, placement]);
useEffect(() => {
if (active && open) bringForward();
}, [active, open]);
useEffect(() => {
if (!active) return undefined;
const handleResize = () => {
setGeometry((current) =>
clampPanelGeometry(current, viewportSize(), minimums),
);
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [active, minimumHeight, minimumWidth]);
const beginInteraction = (
event: PointerEvent<HTMLElement>,
kind: PointerInteraction["kind"],
) => {
if (!active || !open || event.button !== 0) return;
if (
kind === "move" &&
event.target instanceof Element &&
event.target.closest("button")
) {
return;
}
event.currentTarget.focus({ preventScroll: true });
event.preventDefault();
event.currentTarget.setPointerCapture?.(event.pointerId);
interactionRef.current = {
kind,
pointerId: event.pointerId,
clientX: event.clientX,
clientY: event.clientY,
geometry,
};
bringForward();
};
const continueInteraction = (event: PointerEvent<HTMLElement>) => {
const interaction = interactionRef.current;
if (!interaction || interaction.pointerId !== event.pointerId) return;
const deltaX = event.clientX - interaction.clientX;
const deltaY = event.clientY - interaction.clientY;
setGeometry(
interaction.kind === "move"
? movePanelGeometry(
interaction.geometry,
deltaX,
deltaY,
viewportSize(),
minimums,
)
: resizePanelGeometry(
interaction.geometry,
deltaX,
deltaY,
viewportSize(),
minimums,
),
);
};
const endInteraction = (event: PointerEvent<HTMLElement>) => {
if (interactionRef.current?.pointerId !== event.pointerId) return;
interactionRef.current = null;
if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
event.currentTarget.releasePointerCapture?.(event.pointerId);
}
};
const moveWithKeyboard = (event: KeyboardEvent<HTMLElement>) => {
if (event.key === "Escape") {
event.preventDefault();
onClose();
return;
}
const delta = keyboardDelta(event);
if (!delta) return;
event.preventDefault();
bringForward();
setGeometry((current) =>
movePanelGeometry(current, delta.x, delta.y, viewportSize(), minimums),
);
};
const resizeWithKeyboard = (event: KeyboardEvent<HTMLElement>) => {
const delta = keyboardDelta(event);
if (!delta) return;
event.preventDefault();
bringForward();
setGeometry((current) =>
resizePanelGeometry(current, delta.x, delta.y, viewportSize(), minimums),
);
};
const floatingStyle: CSSProperties | undefined = active
? {
left: geometry.x,
top: geometry.y,
width: geometry.width,
height: geometry.height,
zIndex: layer,
}
: undefined;
const modeClass = active
? open
? "is-floating"
: "is-panel-closed"
: embeddedWhenInactive
? "is-embedded"
: "is-dormant";
return (
<section
className={`floating-panel ${modeClass} floating-panel-${placement}`}
style={floatingStyle}
role={active && open ? "region" : undefined}
aria-label={active && open ? `${title} panel` : undefined}
aria-hidden={active && !open ? true : undefined}
data-panel-id={panelId}
onFocusCapture={active && open ? bringForward : undefined}
>
{active && (
<header
className="floating-panel-header"
tabIndex={open ? 0 : -1}
aria-label={`Move ${title} panel. Use arrow keys, or Shift plus arrow keys for larger steps.`}
onPointerDown={(event) => beginInteraction(event, "move")}
onPointerMove={continueInteraction}
onPointerUp={endInteraction}
onPointerCancel={endInteraction}
onKeyDown={moveWithKeyboard}
>
<span className="floating-panel-grip" aria-hidden="true"><i /><i /><i /><i /><i /><i /></span>
<div className="floating-panel-title">
<span>{eyebrow}</span>
<strong>{title}</strong>
<small>{subtitle}</small>
</div>
<span className="floating-panel-active"><i /> {analysisActive ? "Analysis active" : "Analysis stopped"}</span>
<button
type="button"
className="floating-panel-stop"
disabled={stopDisabled || !analysisActive}
aria-label={`Stop analysis from ${title.toLowerCase()} panel`}
onClick={onStop}
>
<Icon name="stop" size={12} /> Stop analysis
</button>
<button
type="button"
className="floating-panel-close"
aria-label={`Close ${title.toLowerCase()} panel; ${analysisActive ? "analysis will continue" : "results remain available"}`}
onClick={onClose}
>
<span aria-hidden="true">×</span>
</button>
</header>
)}
<div className="floating-panel-body">{children}</div>
{active && (
<span
className="floating-panel-resize"
role="button"
tabIndex={open ? 0 : -1}
aria-label={`Resize ${title} panel. Use arrow keys, or Shift plus arrow keys for larger steps.`}
onPointerDown={(event) => beginInteraction(event, "resize")}
onPointerMove={continueInteraction}
onPointerUp={endInteraction}
onPointerCancel={endInteraction}
onKeyDown={resizeWithKeyboard}
>
<i /><i /><i />
</span>
)}
</section>
);
}