146 lines
4.4 KiB
TypeScript
146 lines
4.4 KiB
TypeScript
import { useEffect, useId, useLayoutEffect, useRef } from "react";
|
|
import type { ReactNode } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { useLift } from "./LiftContext";
|
|
|
|
/** Recursively strip CSS animations and transitions from a cloned DOM tree */
|
|
function stripAnimations(node: Node) {
|
|
if (node instanceof HTMLElement) {
|
|
node.style.animation = "none";
|
|
node.style.transition = "none";
|
|
node.getAnimations().forEach((a) => a.cancel());
|
|
}
|
|
for (const child of Array.from(node.childNodes)) {
|
|
stripAnimations(child);
|
|
}
|
|
}
|
|
|
|
function cloneWithoutAnimations(source: HTMLElement, target: HTMLElement) {
|
|
target.innerHTML = "";
|
|
for (const child of Array.from(source.children)) {
|
|
const clone = child.cloneNode(true) as HTMLElement;
|
|
stripAnimations(clone);
|
|
target.appendChild(clone);
|
|
}
|
|
}
|
|
|
|
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, modalOpen, registerAlways, unregisterAlways } = useLift();
|
|
|
|
useEffect(() => {
|
|
if (always) {
|
|
registerAlways(id);
|
|
return () => unregisterAlways(id);
|
|
}
|
|
}, [always, id, registerAlways, unregisterAlways]);
|
|
|
|
const isLifted = liftedIds.has(id) || (alwaysLiftedIds.has(id) && modalOpen);
|
|
|
|
const element = (
|
|
<Liftable id={id}>{render({ isLifted, ...extraProps } as { isLifted: boolean } & T)}</Liftable>
|
|
);
|
|
|
|
return { id, element };
|
|
}
|
|
|
|
export { cloneWithoutAnimations };
|
|
|
|
export function Liftable({ id: idProp, always, children }: Props) {
|
|
const autoId = useId();
|
|
const id = idProp ?? autoId;
|
|
const {
|
|
liftedIds,
|
|
alwaysLiftedIds,
|
|
modalOpen,
|
|
registerAlways,
|
|
unregisterAlways,
|
|
portalContainer,
|
|
} = useLift();
|
|
|
|
const childrenHostRef = useRef<HTMLDivElement>(null);
|
|
const inlineSlotRef = useRef<HTMLDivElement>(null);
|
|
const portalWrapperRef = useRef<HTMLDivElement>(null);
|
|
const wasLiftedRef = useRef(false);
|
|
|
|
useEffect(() => {
|
|
if (always) {
|
|
registerAlways(id);
|
|
return () => unregisterAlways(id);
|
|
}
|
|
}, [always, id, registerAlways, unregisterAlways]);
|
|
|
|
const isLifted = liftedIds.has(id) || (alwaysLiftedIds.has(id) && modalOpen);
|
|
|
|
useLayoutEffect(() => {
|
|
const host = childrenHostRef.current;
|
|
const inlineSlot = inlineSlotRef.current;
|
|
const portalWrapper = portalWrapperRef.current;
|
|
if (!host || !inlineSlot || !portalWrapper) return;
|
|
|
|
if (isLifted && !wasLiftedRef.current) {
|
|
const rect = inlineSlot.getBoundingClientRect();
|
|
cloneWithoutAnimations(host, inlineSlot);
|
|
portalWrapper.appendChild(host);
|
|
portalWrapper.style.cssText = `position:fixed;top:${rect.top}px;left:${rect.left}px;width:${rect.width}px;height:${rect.height}px;pointer-events:auto`;
|
|
wasLiftedRef.current = true;
|
|
} else if (!isLifted && wasLiftedRef.current) {
|
|
inlineSlot.innerHTML = "";
|
|
inlineSlot.appendChild(host);
|
|
portalWrapper.style.cssText = "display:none";
|
|
wasLiftedRef.current = false;
|
|
}
|
|
}, [isLifted]);
|
|
|
|
useEffect(() => {
|
|
if (!isLifted || !inlineSlotRef.current || !portalWrapperRef.current) return;
|
|
const portalWrapper = portalWrapperRef.current;
|
|
const inlineSlot = inlineSlotRef.current;
|
|
|
|
const measure = () => {
|
|
const rect = inlineSlot.getBoundingClientRect();
|
|
portalWrapper.style.top = `${rect.top}px`;
|
|
portalWrapper.style.left = `${rect.left}px`;
|
|
portalWrapper.style.width = `${rect.width}px`;
|
|
portalWrapper.style.height = `${rect.height}px`;
|
|
};
|
|
|
|
const observer = new ResizeObserver(measure);
|
|
observer.observe(inlineSlot);
|
|
window.addEventListener("resize", measure);
|
|
|
|
return () => {
|
|
observer.disconnect();
|
|
window.removeEventListener("resize", measure);
|
|
};
|
|
}, [isLifted]);
|
|
|
|
return (
|
|
<>
|
|
<div ref={inlineSlotRef}>
|
|
<div ref={childrenHostRef}>{children}</div>
|
|
</div>
|
|
{portalContainer &&
|
|
createPortal(<div ref={portalWrapperRef} style={{ display: "none" }} />, portalContainer)}
|
|
</>
|
|
);
|
|
}
|