mirror of
https://github.com/p-stream/p-stream.git
synced 2026-01-11 20:10:32 +00:00
164 lines
4.9 KiB
TypeScript
164 lines
4.9 KiB
TypeScript
import classNames from "classnames";
|
|
import FocusTrap from "focus-trap-react";
|
|
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
|
|
import { Transition } from "@/components/utils/Transition";
|
|
import {
|
|
useInternalOverlayRouter,
|
|
useRouterAnchorUpdate,
|
|
} from "@/hooks/useOverlayRouter";
|
|
|
|
export interface OverlayProps {
|
|
id: string;
|
|
children?: ReactNode;
|
|
darken?: boolean;
|
|
}
|
|
|
|
export function OverlayDisplay(props: { children: ReactNode }) {
|
|
const router = useInternalOverlayRouter("hello world :)");
|
|
const refRouter = useRef(router);
|
|
|
|
// close router on first mount, we dont want persist routes for overlays
|
|
useEffect(() => {
|
|
const r = refRouter.current;
|
|
r.close();
|
|
return () => {
|
|
r.close();
|
|
};
|
|
}, []);
|
|
return <div className="popout-location">{props.children}</div>;
|
|
}
|
|
|
|
export function OverlayPortal(props: {
|
|
children?: ReactNode;
|
|
darken?: boolean;
|
|
show?: boolean;
|
|
close?: () => void;
|
|
durationClass?: string;
|
|
zIndex?: number;
|
|
}) {
|
|
const [portalElement, setPortalElement] = useState<Element | null>(null);
|
|
const [isReady, setIsReady] = useState(false);
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const close = props.close;
|
|
const zIndex = props.zIndex ?? 999;
|
|
|
|
useEffect(() => {
|
|
const element = ref.current?.closest(".popout-location");
|
|
setPortalElement(element ?? document.body);
|
|
|
|
// Ensure DOM is ready before enabling focus trap
|
|
const timer = setTimeout(() => {
|
|
setIsReady(true);
|
|
}, 100); // Increased delay to ensure DOM is fully rendered
|
|
|
|
return () => clearTimeout(timer);
|
|
}, []);
|
|
|
|
// Add global error handler for unhandled promise rejections
|
|
useEffect(() => {
|
|
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
|
|
if (
|
|
event.reason &&
|
|
typeof event.reason === "object" &&
|
|
"message" in event.reason
|
|
) {
|
|
const message = event.reason.message;
|
|
if (
|
|
message &&
|
|
typeof message === "string" &&
|
|
message.includes("matches.call")
|
|
) {
|
|
console.warn(
|
|
"Caught focus-trap matches.call error, preventing crash:",
|
|
event.reason,
|
|
);
|
|
event.preventDefault();
|
|
}
|
|
}
|
|
};
|
|
|
|
window.addEventListener("unhandledrejection", handleUnhandledRejection);
|
|
return () =>
|
|
window.removeEventListener(
|
|
"unhandledrejection",
|
|
handleUnhandledRejection,
|
|
);
|
|
}, []);
|
|
|
|
return (
|
|
<div ref={ref}>
|
|
{portalElement
|
|
? createPortal(
|
|
<Transition show={props.show} animation="none">
|
|
<FocusTrap
|
|
active={isReady && !!props.show}
|
|
focusTrapOptions={{
|
|
allowOutsideClick: true,
|
|
clickOutsideDeactivates: true,
|
|
fallbackFocus: () => document.body,
|
|
returnFocusOnDeactivate: true,
|
|
escapeDeactivates: false, // Let our keyboard handler manage escape
|
|
preventScroll: true,
|
|
// Disable the problematic check that causes the matches.call error
|
|
checkCanFocusTrap: () => Promise.resolve(),
|
|
}}
|
|
>
|
|
<div
|
|
className="popout-wrapper fixed overflow-hidden pointer-events-auto inset-0 select-none"
|
|
style={{ zIndex }}
|
|
>
|
|
<Transition animation="fade" isChild>
|
|
<div
|
|
onClick={close}
|
|
className={classNames({
|
|
"absolute inset-0": true,
|
|
"bg-black opacity-90": props.darken,
|
|
})}
|
|
/>
|
|
</Transition>
|
|
<Transition
|
|
animation="slide-up"
|
|
className="absolute inset-0 pointer-events-none"
|
|
isChild
|
|
durationClass={props.durationClass ?? "duration-200"}
|
|
>
|
|
{/* a tabable index that does nothing - used so focus trap doesn't error when nothing is rendered yet */}
|
|
<div
|
|
tabIndex={1}
|
|
className="focus:ring-0 focus:outline-none opacity-0"
|
|
/>
|
|
{props.children}
|
|
</Transition>
|
|
</div>
|
|
</FocusTrap>
|
|
</Transition>,
|
|
portalElement,
|
|
)
|
|
: null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function Overlay(props: OverlayProps) {
|
|
const router = useInternalOverlayRouter(props.id);
|
|
const realClose = router.close;
|
|
|
|
// listen for anchor updates
|
|
useRouterAnchorUpdate(props.id);
|
|
|
|
const close = useCallback(() => {
|
|
realClose();
|
|
}, [realClose]);
|
|
|
|
return (
|
|
<OverlayPortal
|
|
close={close}
|
|
show={router.isOverlayActive()}
|
|
darken={props.darken}
|
|
>
|
|
{props.children}
|
|
</OverlayPortal>
|
|
);
|
|
}
|