122 lines
3.2 KiB
TypeScript
122 lines
3.2 KiB
TypeScript
|
|
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
||
|
|
import type { ReactNode } from "react";
|
||
|
|
import { createPortal } from "react-dom";
|
||
|
|
import { useLift } from "./LiftContext";
|
||
|
|
|
||
|
|
type LiftableProps = {
|
||
|
|
id?: string;
|
||
|
|
always?: boolean;
|
||
|
|
};
|
||
|
|
|
||
|
|
type Props = LiftableProps & {
|
||
|
|
children: ReactNode;
|
||
|
|
};
|
||
|
|
|
||
|
|
export function useLiftable<T extends Record<string, unknown> = Record<string, never>>(
|
||
|
|
render: (props: { isLifted: boolean } & T) => ReactNode,
|
||
|
|
options?: LiftableProps & T,
|
||
|
|
): { id: string; element: ReactNode } {
|
||
|
|
const autoId = useId();
|
||
|
|
const {
|
||
|
|
id: idProp,
|
||
|
|
always,
|
||
|
|
...extraProps
|
||
|
|
} = (options ?? {}) as LiftableProps & Record<string, unknown>;
|
||
|
|
const id = idProp ?? autoId;
|
||
|
|
const { liftedIds, alwaysLiftedIds, registerAlways, unregisterAlways } = useLift();
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (always) {
|
||
|
|
registerAlways(id);
|
||
|
|
return () => unregisterAlways(id);
|
||
|
|
}
|
||
|
|
}, [always, id, registerAlways, unregisterAlways]);
|
||
|
|
|
||
|
|
const isLifted = liftedIds.has(id) || alwaysLiftedIds.has(id);
|
||
|
|
|
||
|
|
const element = (
|
||
|
|
<Liftable id={id}>{render({ isLifted, ...extraProps } as { isLifted: boolean } & T)}</Liftable>
|
||
|
|
);
|
||
|
|
|
||
|
|
return { id, element };
|
||
|
|
}
|
||
|
|
|
||
|
|
export function Liftable({ id: idProp, always, children }: Props) {
|
||
|
|
const autoId = useId();
|
||
|
|
const id = idProp ?? autoId;
|
||
|
|
const { liftedIds, alwaysLiftedIds, registerAlways, unregisterAlways, portalContainer } =
|
||
|
|
useLift();
|
||
|
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||
|
|
const [rect, setRect] = useState<DOMRect | null>(null);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (always) {
|
||
|
|
registerAlways(id);
|
||
|
|
return () => unregisterAlways(id);
|
||
|
|
}
|
||
|
|
}, [always, id, registerAlways, unregisterAlways]);
|
||
|
|
|
||
|
|
const isLifted = liftedIds.has(id) || alwaysLiftedIds.has(id);
|
||
|
|
|
||
|
|
useLayoutEffect(() => {
|
||
|
|
if (isLifted && wrapperRef.current) {
|
||
|
|
setRect(wrapperRef.current.getBoundingClientRect());
|
||
|
|
}
|
||
|
|
if (!isLifted) {
|
||
|
|
setRect(null);
|
||
|
|
}
|
||
|
|
}, [isLifted]);
|
||
|
|
|
||
|
|
// Re-measure on resize while lifted
|
||
|
|
useEffect(() => {
|
||
|
|
if (!isLifted || !wrapperRef.current) return;
|
||
|
|
|
||
|
|
const measure = () => {
|
||
|
|
if (wrapperRef.current) {
|
||
|
|
setRect(wrapperRef.current.getBoundingClientRect());
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const observer = new ResizeObserver(measure);
|
||
|
|
observer.observe(wrapperRef.current);
|
||
|
|
window.addEventListener("resize", measure);
|
||
|
|
|
||
|
|
return () => {
|
||
|
|
observer.disconnect();
|
||
|
|
window.removeEventListener("resize", measure);
|
||
|
|
};
|
||
|
|
}, [isLifted]);
|
||
|
|
|
||
|
|
// When lifted and we have measurements + portal target, render in portal
|
||
|
|
if (isLifted && rect && portalContainer) {
|
||
|
|
return (
|
||
|
|
<>
|
||
|
|
{/* Placeholder preserves layout space */}
|
||
|
|
<div
|
||
|
|
ref={wrapperRef}
|
||
|
|
style={{ width: rect.width, height: rect.height, visibility: "hidden" }}
|
||
|
|
/>
|
||
|
|
{/* Portal children above blur */}
|
||
|
|
{createPortal(
|
||
|
|
<div
|
||
|
|
style={{
|
||
|
|
position: "fixed",
|
||
|
|
top: rect.top,
|
||
|
|
left: rect.left,
|
||
|
|
width: rect.width,
|
||
|
|
height: rect.height,
|
||
|
|
pointerEvents: "auto",
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{children}
|
||
|
|
</div>,
|
||
|
|
portalContainer,
|
||
|
|
)}
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Normal inline rendering
|
||
|
|
return <div ref={wrapperRef}>{children}</div>;
|
||
|
|
}
|