loopable / web /src /customer-grid /OverlaySurface.tsx
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
bf8519f verified
Raw
History Blame Contribute Delete
11.5 kB
import {
createContext,
useCallback,
useContext,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import type {
AriaRole,
CSSProperties,
KeyboardEvent as ReactKeyboardEvent,
ReactNode,
RefObject,
} from "react";
import { createPortal } from "react-dom";
import { computeOverlayPosition } from "./overlayPlacement";
import type { Placement as OverlayPlacement } from "./overlayPlacement";
export interface AnchorRect {
left: number;
top: number;
right: number;
bottom: number;
width: number;
height: number;
}
type Anchor = HTMLElement | AnchorRect;
/**
* Wave-9 I14 adds `right-start`: the panel sits BESIDE the anchor (top edges aligned), not
* under it — the owner's "flyout to the RIGHT". It is a real placement rather than a margin
* hack because the views rail is a 188px column at the left edge of an iframe, and a panel
* absolutely-positioned inside `.cg-views` would be clipped by the rail it escapes; every
* other overlay here is already body-level and fixed for the same reason.
*
* The placement ARITHMETIC moved to overlayPlacement.ts with that change, so the collision
* maths is gated rather than eyeballed (verify_overlay.py).
*/
type Placement = OverlayPlacement;
interface OverlayStack {
register: (id: string) => () => void;
isTop: (id: string) => boolean;
}
const OverlayStackContext = createContext<OverlayStack | null>(null);
const FOCUSABLE = [
"[data-overlay-autofocus]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"[href]",
'[tabindex]:not([tabindex="-1"])',
].join(",");
export function OverlayProvider({ children }: { children: ReactNode }) {
const stack = useRef<string[]>([]);
const api = useMemo<OverlayStack>(
() => ({
register: (id) => {
stack.current = [...stack.current.filter((item) => item !== id), id];
return () => {
stack.current = stack.current.filter((item) => item !== id);
};
},
isTop: (id) => stack.current.at(-1) === id,
}),
[]
);
return (
<OverlayStackContext.Provider value={api}>
{children}
</OverlayStackContext.Provider>
);
}
/**
* ⚠ NULL IS A REACHABLE VALUE HERE, and it must not be fatal.
*
* Half the call sites pass `someRef.current`, which is legitimately null on the render
* before the ref attaches, and `Anchor` does not include null — so every one of them was
* one ordering accident away from `"getBoundingClientRect" in null`, a TypeError thrown
* during RENDER, which unmounts the whole tree. That is not a hypothetical: it is what
* `_qa_live_rail.py` reproduced on the shipped build when the saved-view menu's anchor
* came back null (2026-08-04).
*
* A missing anchor is a positioning problem, not a reason to lose the application. The
* panel degrades to a zero-rect at the viewport origin — visible, dismissible, obviously
* wrong — while the root causes stay fixable at their own call sites.
*/
const NO_RECT: AnchorRect = { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 };
function anchorRect(anchor: Anchor | null | undefined): AnchorRect {
if (!anchor) return NO_RECT;
if ("getBoundingClientRect" in anchor) {
const rect = anchor.getBoundingClientRect();
return {
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
width: rect.width,
height: rect.height,
};
}
return anchor;
}
function samePosition(a: CSSProperties, b: CSSProperties): boolean {
return (
a.left === b.left &&
a.top === b.top &&
a.maxWidth === b.maxWidth &&
a.maxHeight === b.maxHeight &&
a.visibility === b.visibility
);
}
interface OverlayLayerOptions {
panelRef: RefObject<HTMLElement | null>;
onDismiss: () => void;
dismissOnOutside?: boolean;
initialFocus?: "first" | "none" | string;
restoreFocus?: boolean;
trapFocus?: boolean;
outsideElements?: Array<HTMLElement | null>;
}
/** Shared dismissal/focus contract for anchored menus and fixed drawers. */
// oxlint-disable-next-line react/only-export-components -- shares the private overlay stack context.
export function useOverlayLayer({
panelRef,
onDismiss,
dismissOnOutside = true,
initialFocus = "first",
restoreFocus = true,
trapFocus = false,
outsideElements = [],
}: OverlayLayerOptions): void {
const id = useId();
const stack = useContext(OverlayStackContext);
const dismissRef = useRef(onDismiss);
const outsideRef = useRef(outsideElements);
dismissRef.current = onDismiss;
outsideRef.current = outsideElements;
useEffect(() => stack?.register(id), [id, stack]);
useEffect(() => {
const onPointerDown = (event: PointerEvent) => {
if (!dismissOnOutside || (stack && !stack.isTop(id))) return;
const target = event.target as Node | null;
if (!target || panelRef.current?.contains(target)) return;
if (outsideRef.current.some((element) => element?.contains(target))) return;
if (
document.activeElement instanceof HTMLElement &&
panelRef.current?.contains(document.activeElement)
)
document.activeElement.blur();
dismissRef.current();
};
const onKeyDown = (event: KeyboardEvent) => {
if (stack && !stack.isTop(id)) return;
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
if (
document.activeElement instanceof HTMLElement &&
panelRef.current?.contains(document.activeElement)
)
document.activeElement.blur();
dismissRef.current();
return;
}
if (event.key !== "Tab" || !trapFocus) return;
const focusable = Array.from(
panelRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE) ?? []
).filter((element) => element.getClientRects().length > 0);
if (!focusable.length) {
event.preventDefault();
panelRef.current?.focus();
return;
}
const first = focusable[0];
const last = focusable.at(-1)!;
const active = document.activeElement;
if (event.shiftKey && (active === first || !panelRef.current?.contains(active))) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
}
};
document.addEventListener("pointerdown", onPointerDown, true);
document.addEventListener("keydown", onKeyDown, true);
return () => {
document.removeEventListener("pointerdown", onPointerDown, true);
document.removeEventListener("keydown", onKeyDown, true);
};
}, [dismissOnOutside, id, panelRef, stack, trapFocus]);
useLayoutEffect(() => {
const previous =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
const frame = window.requestAnimationFrame(() => {
if (initialFocus === "none") return;
const selector = initialFocus === "first" ? FOCUSABLE : initialFocus;
const target = panelRef.current?.querySelector<HTMLElement>(selector);
target?.focus({ preventScroll: true });
});
return () => {
window.cancelAnimationFrame(frame);
if (restoreFocus && previous?.isConnected) previous.focus({ preventScroll: true });
};
}, [initialFocus, panelRef, restoreFocus]);
}
export function BodyPortal({ children }: { children: ReactNode }) {
return createPortal(children, document.body);
}
interface AnchoredOverlayProps {
/** ⚠ Nullable BY DECLARATION as of 2026-08-04. Several call sites pass `ref.current`,
* which is null before the ref attaches, and the old non-null type made that a
* render-time TypeError instead of a type error. See `anchorRect`. */
anchor: Anchor | null | undefined;
className: string;
children: ReactNode;
onDismiss: () => void;
placement?: Placement;
role?: AriaRole;
ariaLabel?: string;
id?: string;
initialFocus?: "first" | "none" | string;
restoreFocus?: boolean;
dismissOnOutside?: boolean;
onKeyDown?: (event: ReactKeyboardEvent<HTMLElement>) => void;
dataKind?: string;
}
/** Body-level fixed overlay with iframe-viewport collision handling. */
export function AnchoredOverlay({
anchor,
className,
children,
onDismiss,
placement = "bottom-start",
role,
ariaLabel,
id,
initialFocus = "first",
restoreFocus = true,
dismissOnOutside = true,
onKeyDown,
dataKind,
}: AnchoredOverlayProps) {
const panelRef = useRef<HTMLDivElement>(null);
const [style, setStyle] = useState<CSSProperties>({
position: "fixed",
left: 0,
top: 0,
visibility: "hidden",
});
const anchorElement = anchor && "getBoundingClientRect" in anchor ? anchor : null;
useOverlayLayer({
panelRef,
onDismiss,
dismissOnOutside,
initialFocus,
restoreFocus,
outsideElements: [anchorElement],
});
const updatePosition = useCallback(() => {
const panel = panelRef.current;
if (!panel) return;
const target = anchorRect(anchor);
const viewport = window.visualViewport;
const viewportLeft = viewport?.offsetLeft ?? 0;
const viewportTop = viewport?.offsetTop ?? 0;
const viewportWidth = viewport?.width ?? window.innerWidth;
const viewportHeight = viewport?.height ?? window.innerHeight;
const measured = panel.getBoundingClientRect();
// The arithmetic lives in overlayPlacement.ts so a gate can run it under node. This
// reads the DOM, decides nothing.
const placed = computeOverlayPosition({
placement,
target,
panel: {
width: Math.max(measured.width, panel.scrollWidth),
height: Math.max(measured.height, panel.scrollHeight),
},
viewport: {
left: viewportLeft,
top: viewportTop,
width: viewportWidth,
height: viewportHeight,
},
});
const next: CSSProperties = {
position: "fixed",
left: placed.left,
top: placed.top,
maxWidth: placed.maxWidth,
maxHeight: placed.maxHeight,
visibility: "visible",
};
setStyle((current) => (samePosition(current, next) ? current : next));
}, [anchor, placement]);
useLayoutEffect(() => {
updatePosition();
const frame = window.requestAnimationFrame(updatePosition);
const observer = new ResizeObserver(updatePosition);
if (panelRef.current) observer.observe(panelRef.current);
if (anchorElement) observer.observe(anchorElement);
window.addEventListener("resize", updatePosition);
window.addEventListener("scroll", updatePosition, true);
window.visualViewport?.addEventListener("resize", updatePosition);
window.visualViewport?.addEventListener("scroll", updatePosition);
return () => {
window.cancelAnimationFrame(frame);
observer.disconnect();
window.removeEventListener("resize", updatePosition);
window.removeEventListener("scroll", updatePosition, true);
window.visualViewport?.removeEventListener("resize", updatePosition);
window.visualViewport?.removeEventListener("scroll", updatePosition);
};
}, [anchorElement, updatePosition]);
return (
<BodyPortal>
<div
ref={panelRef}
id={id}
className={className}
style={style}
role={role}
aria-label={ariaLabel}
data-overlay-kind={dataKind}
onKeyDown={onKeyDown}
>
{children}
</div>
</BodyPortal>
);
}