11 changed files with 314 additions and 42 deletions
@ -0,0 +1,47 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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