import { Fragment, createContext, useContext, useEffect, useId, useMemo, useRef, useState, type ReactNode, } from "react"; import { createSyncBatcher, type SyncBatcher } from "../utils/sync-batcher.js"; interface OverlayEntry { id: string; node: ReactNode; priority: number; } interface OverlayHostContextValue { setOverlay: (entry: OverlayEntry) => void; removeOverlay: (id: string) => void; } type OverlayAction = { type: "set"; entry: OverlayEntry } | { type: "remove"; id: string }; const OverlayHostContext = createContext(null); export interface OverlayHostProviderProps { children: ReactNode; } export function OverlayHostProvider({ children }: OverlayHostProviderProps) { const [entries, setEntries] = useState>({}); const batcherRef = useRef | null>(null); useEffect(() => { batcherRef.current = createSyncBatcher( (actions) => { setEntries((prev) => { let next = prev; let changed = false; for (const action of actions) { if (action.type === "set") { const existing = next[action.entry.id]; if ( existing && existing.priority === action.entry.priority && existing.node === action.entry.node ) { continue; } if (!changed) { next = { ...next }; changed = true; } next[action.entry.id] = action.entry; continue; } if (!next[action.id]) { continue; } if (!changed) { next = { ...next }; changed = true; } delete next[action.id]; } return changed ? next : prev; }); }, { mode: typeof process !== "undefined" && process.env.NODE_ENV === "test" ? "sync" : "microtask", }, ); return () => { batcherRef.current?.dispose(); batcherRef.current = null; }; }, []); const value = useMemo( () => ({ setOverlay(entry) { const batcher = batcherRef.current; if (!batcher) { setEntries((prev) => ({ ...prev, [entry.id]: entry })); return; } batcher.enqueue({ type: "set", entry }); }, removeOverlay(id) { const batcher = batcherRef.current; if (!batcher) { setEntries((prev) => { if (!prev[id]) return prev; const next = { ...prev }; delete next[id]; return next; }); return; } batcher.enqueue({ type: "remove", id }); }, }), [], ); const ordered = useMemo( () => Object.values(entries).sort((left, right) => left.priority - right.priority), [entries], ); return ( {children} {ordered.map((entry) => ( {entry.node} ))} ); } export interface OverlayPortalProps { active?: boolean; priority?: number; children: ReactNode; } export function OverlayPortal({ active = true, priority = 0, children }: OverlayPortalProps) { const context = useContext(OverlayHostContext); const id = useId(); useEffect(() => { if (!context) return; if (active) { context.setOverlay({ id, node: children, priority }); } else { context.removeOverlay(id); } return () => { context.removeOverlay(id); }; }, [active, children, context, id, priority]); if (!context) { if (!active) return null; return children; } return null; }