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 = Record>( 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; 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 = ( {render({ isLifted, ...extraProps } as { isLifted: boolean } & T)} ); 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(null); const inlineSlotRef = useRef(null); const portalWrapperRef = useRef(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 ( <>
{children}
{portalContainer && createPortal(
, portalContainer)} ); }