11 changed files with 314 additions and 42 deletions
@ -0,0 +1,47 @@ |
|||||||
|
import { ReactNode, useEffect, useRef } from "react"; |
||||||
|
|
||||||
|
export function createOverlayAnchorEvent(id: string): string { |
||||||
|
return `__overlay::anchor::${id}`; |
||||||
|
} |
||||||
|
|
||||||
|
interface Props { |
||||||
|
id: string; |
||||||
|
children?: ReactNode; |
||||||
|
} |
||||||
|
|
||||||
|
export function OverlayAnchor(props: Props) { |
||||||
|
const ref = useRef<HTMLDivElement>(null); |
||||||
|
const old = useRef<string | null>(null); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (!ref.current) return; |
||||||
|
|
||||||
|
let cancelled = false; |
||||||
|
function render() { |
||||||
|
if (cancelled) return; |
||||||
|
|
||||||
|
if (ref.current) { |
||||||
|
const current = old.current; |
||||||
|
const newer = ref.current.getBoundingClientRect(); |
||||||
|
const newerStr = JSON.stringify(newer); |
||||||
|
if (current !== newerStr) { |
||||||
|
old.current = newerStr; |
||||||
|
const evtStr = createOverlayAnchorEvent(props.id); |
||||||
|
(window as any)[evtStr] = newer; |
||||||
|
const evObj = new CustomEvent(createOverlayAnchorEvent(props.id), { |
||||||
|
detail: newer, |
||||||
|
}); |
||||||
|
document.dispatchEvent(evObj); |
||||||
|
} |
||||||
|
} |
||||||
|
window.requestAnimationFrame(render); |
||||||
|
} |
||||||
|
|
||||||
|
window.requestAnimationFrame(render); |
||||||
|
return () => { |
||||||
|
cancelled = true; |
||||||
|
}; |
||||||
|
}, [props]); |
||||||
|
|
||||||
|
return <div ref={ref}>{props.children}</div>; |
||||||
|
} |
@ -0,0 +1,79 @@ |
|||||||
|
import classNames from "classnames"; |
||||||
|
import { ReactNode, useCallback, useEffect, useRef, useState } from "react"; |
||||||
|
import { createPortal } from "react-dom"; |
||||||
|
|
||||||
|
import { Transition } from "@/components/Transition"; |
||||||
|
|
||||||
|
export interface OverlayProps { |
||||||
|
children?: ReactNode; |
||||||
|
onClose?: () => void; |
||||||
|
show?: boolean; |
||||||
|
darken?: boolean; |
||||||
|
} |
||||||
|
|
||||||
|
export function OverlayDisplay(props: { children: ReactNode }) { |
||||||
|
return <div className="popout-location">{props.children}</div>; |
||||||
|
} |
||||||
|
|
||||||
|
export function Overlay(props: OverlayProps) { |
||||||
|
const [portalElement, setPortalElement] = useState<Element | null>(null); |
||||||
|
const ref = useRef<HTMLDivElement>(null); |
||||||
|
const target = useRef<Element | null>(null); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
function listen(e: MouseEvent) { |
||||||
|
target.current = e.target as Element; |
||||||
|
} |
||||||
|
document.addEventListener("mousedown", listen); |
||||||
|
return () => { |
||||||
|
document.removeEventListener("mousedown", listen); |
||||||
|
}; |
||||||
|
}); |
||||||
|
|
||||||
|
const click = useCallback( |
||||||
|
(e: React.MouseEvent) => { |
||||||
|
const startedTarget = target.current; |
||||||
|
target.current = null; |
||||||
|
if (e.currentTarget !== e.target) return; |
||||||
|
if (!startedTarget) return; |
||||||
|
if (!startedTarget.isEqualNode(e.currentTarget as Element)) return; |
||||||
|
if (props.onClose) props.onClose(); |
||||||
|
}, |
||||||
|
[props] |
||||||
|
); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
const element = ref.current?.closest(".popout-location"); |
||||||
|
setPortalElement(element ?? document.body); |
||||||
|
}, []); |
||||||
|
|
||||||
|
const backdrop = ( |
||||||
|
<Transition animation="fade" isChild> |
||||||
|
<div |
||||||
|
onClick={click} |
||||||
|
className={classNames({ |
||||||
|
"absolute inset-0": true, |
||||||
|
"bg-black opacity-90": props.darken, |
||||||
|
})} |
||||||
|
/> |
||||||
|
</Transition> |
||||||
|
); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div ref={ref}> |
||||||
|
{portalElement |
||||||
|
? createPortal( |
||||||
|
<Transition show={props.show} animation="none"> |
||||||
|
<div className="popout-wrapper pointer-events-auto fixed inset-0 z-[999] select-none"> |
||||||
|
{backdrop} |
||||||
|
<Transition animation="slide-up" className="h-0" isChild> |
||||||
|
{props.children} |
||||||
|
</Transition> |
||||||
|
</div> |
||||||
|
</Transition>, |
||||||
|
portalElement |
||||||
|
) |
||||||
|
: null} |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,42 @@ |
|||||||
|
import classNames from "classnames"; |
||||||
|
import { ReactNode } from "react"; |
||||||
|
|
||||||
|
import { Transition } from "@/components/Transition"; |
||||||
|
import { useIsMobile } from "@/hooks/useIsMobile"; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
children?: ReactNode; |
||||||
|
show?: boolean; |
||||||
|
className?: string; |
||||||
|
height?: number; |
||||||
|
width?: number; |
||||||
|
active?: boolean; // true if a child view is loaded
|
||||||
|
} |
||||||
|
|
||||||
|
export function FloatingView(props: Props) { |
||||||
|
const { isMobile } = useIsMobile(); |
||||||
|
const width = !isMobile ? `${props.width}px` : "100%"; |
||||||
|
return ( |
||||||
|
<Transition |
||||||
|
animation={props.active ? "slide-full-left" : "slide-full-right"} |
||||||
|
className="absolute inset-0" |
||||||
|
durationClass="duration-[400ms]" |
||||||
|
show={props.show} |
||||||
|
> |
||||||
|
<div |
||||||
|
className={classNames([ |
||||||
|
props.className, |
||||||
|
"grid grid-rows-[auto,minmax(0,1fr)]", |
||||||
|
])} |
||||||
|
data-floating-page={props.show ? "true" : undefined} |
||||||
|
style={{ |
||||||
|
height: props.height ? `${props.height}px` : undefined, |
||||||
|
maxHeight: "70vh", |
||||||
|
width: props.width ? width : undefined, |
||||||
|
}} |
||||||
|
> |
||||||
|
{props.children} |
||||||
|
</div> |
||||||
|
</Transition> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,55 @@ |
|||||||
|
import { useQueryParam } from "@/hooks/useQueryParams"; |
||||||
|
|
||||||
|
export function useOverlayRouter(id: string) { |
||||||
|
const [route, setRoute] = useQueryParam("r"); |
||||||
|
const routeParts = (route ?? "").split("/").filter((v) => v.length > 0); |
||||||
|
const routerActive = routeParts.length > 0 && routeParts[0] === id; |
||||||
|
const currentPage = routeParts[routeParts.length - 1] ?? "/"; |
||||||
|
|
||||||
|
function navigate(path: string) { |
||||||
|
const newRoute = [id, ...path.split("/").filter((v) => v.length > 0)]; |
||||||
|
setRoute(newRoute.join("/")); |
||||||
|
} |
||||||
|
|
||||||
|
function isActive(page: string) { |
||||||
|
if (page === "/") return true; |
||||||
|
const index = routeParts.indexOf(page); |
||||||
|
if (index === -1) return false; // not active
|
||||||
|
if (index === routeParts.length - 1) return false; // active but latest route so shouldnt be counted as active
|
||||||
|
return true; |
||||||
|
} |
||||||
|
|
||||||
|
function isCurrentPage(page: string) { |
||||||
|
return routerActive && page === currentPage; |
||||||
|
} |
||||||
|
|
||||||
|
function isLoaded(page: string) { |
||||||
|
if (page === "/") return true; |
||||||
|
return route.includes(page); |
||||||
|
} |
||||||
|
|
||||||
|
function isOverlayActive() { |
||||||
|
return routerActive; |
||||||
|
} |
||||||
|
|
||||||
|
function pageProps(page: string) { |
||||||
|
return { |
||||||
|
show: isCurrentPage(page), |
||||||
|
active: isActive(page), |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
function close() { |
||||||
|
navigate("/"); |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
isOverlayActive, |
||||||
|
navigate, |
||||||
|
close, |
||||||
|
isLoaded, |
||||||
|
isCurrentPage, |
||||||
|
pageProps, |
||||||
|
isActive, |
||||||
|
}; |
||||||
|
} |
Loading…
Reference in new issue