Files
honey-fe/src/components/lift/Liftable.tsx

122 lines
3.2 KiB
TypeScript
Raw Normal View History

2026-03-22 04:08:56 +02:00
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>;
}