overlay router

This commit is contained in:
mrjvs 2023-10-09 21:00:58 +02:00
parent d9855cb244
commit d485d3200b
6 changed files with 152 additions and 56 deletions

View File

@ -3,7 +3,7 @@ import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Transition } from "@/components/Transition"; import { Transition } from "@/components/Transition";
import { useOverlayRouter } from "@/hooks/useOverlayRouter"; import { useInternalOverlayRouter } from "@/hooks/useOverlayRouter";
export interface OverlayProps { export interface OverlayProps {
id: string; id: string;
@ -16,11 +16,21 @@ export function OverlayDisplay(props: { children: ReactNode }) {
} }
export function Overlay(props: OverlayProps) { export function Overlay(props: OverlayProps) {
const router = useOverlayRouter(props.id); const router = useInternalOverlayRouter(props.id);
const refRouter = useRef(router);
const [portalElement, setPortalElement] = useState<Element | null>(null); const [portalElement, setPortalElement] = useState<Element | null>(null);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const target = useRef<Element | null>(null); const target = useRef<Element | null>(null);
// close router on first mount, we dont want persist routes for overlays
useEffect(() => {
const r = refRouter.current;
r.close();
return () => {
r.close();
};
}, []);
useEffect(() => { useEffect(() => {
function listen(e: MouseEvent) { function listen(e: MouseEvent) {
target.current = e.target as Element; target.current = e.target as Element;

View File

@ -3,32 +3,37 @@ import { ReactNode } from "react";
import { Transition } from "@/components/Transition"; import { Transition } from "@/components/Transition";
import { useIsMobile } from "@/hooks/useIsMobile"; import { useIsMobile } from "@/hooks/useIsMobile";
import { useInternalOverlayRouter } from "@/hooks/useOverlayRouter";
interface Props { interface Props {
id: string;
path: string;
children?: ReactNode; children?: ReactNode;
show?: boolean;
className?: string; className?: string;
height?: number; height?: number;
width?: number; width?: number;
active?: boolean; // true if a child view is loaded
} }
export function OverlayPage(props: Props) { export function OverlayPage(props: Props) {
const router = useInternalOverlayRouter(props.id);
const backwards = router.showBackwardsTransition(props.path);
const show = router.isCurrentPage(props.path);
const { isMobile } = useIsMobile(); const { isMobile } = useIsMobile();
const width = !isMobile ? `${props.width}px` : "100%"; const width = !isMobile ? `${props.width}px` : "100%";
return ( return (
<Transition <Transition
animation={props.active ? "slide-full-left" : "slide-full-right"} animation={backwards ? "slide-full-left" : "slide-full-right"}
className="absolute inset-0" className="absolute inset-0"
durationClass="duration-[400ms]" durationClass="duration-[400ms]"
show={props.show} show={show}
> >
<div <div
className={classNames([ className={classNames([
props.className, props.className,
"grid grid-rows-[auto,minmax(0,1fr)]", "grid grid-rows-[auto,minmax(0,1fr)]",
])} ])}
data-floating-page={props.show ? "true" : undefined} data-floating-page={show ? "true" : undefined}
style={{ style={{
height: props.height ? `${props.height}px` : undefined, height: props.height ? `${props.height}px` : undefined,
maxHeight: "70vh", maxHeight: "70vh",

View File

@ -1,59 +1,80 @@
import { useQueryParam } from "@/hooks/useQueryParams"; import { useCallback } from "react";
export function useOverlayRouter(id: string) { import { useQueryParam } from "@/hooks/useQueryParams";
import { useOverlayStore } from "@/stores/overlay/store";
function splitPath(path: string, prefix?: string): string[] {
const parts = [prefix ?? "", ...path.split("/")];
return parts.filter((v) => v.length > 0);
}
function joinPath(path: string[]): string {
return `/${path.join("/")}`;
}
export function useInternalOverlayRouter(id: string) {
const [route, setRoute] = useQueryParam("r"); const [route, setRoute] = useQueryParam("r");
const routeParts = (route ?? "").split("/").filter((v) => v.length > 0); const transition = useOverlayStore((s) => s.transition);
const routerActive = routeParts.length > 0 && routeParts[0] === id; const setTransition = useOverlayStore((s) => s.setTransition);
const routerActive = !!route && route.startsWith(`/${id}`);
function navigate(path: string) { function navigate(path: string) {
const newRoute = [id, ...path.split("/").filter((v) => v.length > 0)]; const oldRoute = route;
setRoute(newRoute.join("/")); const newRoute = joinPath(splitPath(path, id));
setTransition({
from: oldRoute ?? "/",
to: newRoute,
});
setRoute(newRoute);
} }
function isActive(page: string) { function showBackwardsTransition(path: string) {
if (page === "/") return true; if (!transition) return false;
const index = routeParts.indexOf(page); const current = joinPath(splitPath(path, id));
if (index === -1) return false; // not active
if (index === routeParts.length - 1) return false; // active but latest route so shouldnt be counted as active if (current === transition.to && transition.from.startsWith(transition.to))
return true; return true;
if (
current === transition.from &&
transition.to.startsWith(transition.from)
)
return true;
return false;
} }
function isCurrentPage(page: string) { function isCurrentPage(path: string) {
return routerActive && route === `/${id}${page}`; return routerActive && route === joinPath(splitPath(path, id));
}
function isLoaded(page: string) {
if (page === "/") return true;
return route.includes(page);
} }
function isOverlayActive() { function isOverlayActive() {
return routerActive; return routerActive;
} }
function pageProps(page: string) { const close = useCallback(() => {
return { setTransition(null);
show: isCurrentPage(page),
active: isActive(page),
};
}
function close() {
setRoute(null); setRoute(null);
} }, [setRoute, setTransition]);
function open() { const open = useCallback(() => {
setTransition(null);
setRoute(`/${id}`); setRoute(`/${id}`);
} }, [id, setRoute, setTransition]);
return { return {
showBackwardsTransition,
isCurrentPage,
isOverlayActive, isOverlayActive,
navigate, navigate,
close, close,
isLoaded,
isCurrentPage,
pageProps,
isActive,
open, open,
}; };
} }
export function useOverlayRouter(id: string) {
const router = useInternalOverlayRouter(id);
return {
open: router.open,
close: router.close,
navigate: router.navigate,
};
}

View File

@ -16,11 +16,13 @@ export function useQueryParams() {
return queryParams; return queryParams;
} }
export function useQueryParam(param: string) { export function useQueryParam(
param: string
): [string | null, (a: string | null) => void] {
const params = useQueryParams(); const params = useQueryParams();
const location = useLocation(); const location = useLocation();
const router = useHistory(); const router = useHistory();
const currentValue = params[param]; const currentValue = params[param] ?? null;
const set = useCallback( const set = useCallback(
(value: string | null) => { (value: string | null) => {
@ -34,5 +36,5 @@ export function useQueryParam(param: string) {
[param, location, router] [param, location, router]
); );
return [currentValue, set] as const; return [currentValue, set];
} }

View File

@ -6,7 +6,6 @@ import { useOverlayRouter } from "@/hooks/useOverlayRouter";
// simple empty view, perfect for putting in tests // simple empty view, perfect for putting in tests
export default function TestView() { export default function TestView() {
const router = useOverlayRouter("test"); const router = useOverlayRouter("test");
const pages = ["", "/one", "/two"];
return ( return (
<OverlayDisplay> <OverlayDisplay>
@ -19,21 +18,57 @@ export default function TestView() {
> >
Open Open
</button> </button>
<button
type="button"
onClick={() => {
router.navigate(pages[Math.floor(pages.length * Math.random())]);
}}
>
random page
</button>
<OverlayAnchor id="test"> <OverlayAnchor id="test">
<div className="h-20 w-20 bg-white" /> <div className="h-20 w-20 bg-white" />
</OverlayAnchor> </OverlayAnchor>
<Overlay id="test"> <Overlay id="test">
<OverlayPage {...router.pageProps("")}>Home</OverlayPage> <OverlayPage id="test" path="/">
<OverlayPage {...router.pageProps("/one")}>Page one</OverlayPage> <div className="bg-blue-900 p-4">
<OverlayPage {...router.pageProps("/two")}>Page two</OverlayPage> <p>HOME</p>
<button
type="button"
onClick={() => {
router.navigate("/two");
}}
>
open page two
</button>
<button
type="button"
onClick={() => {
router.navigate("/one");
}}
>
open page one
</button>
</div>
</OverlayPage>
<OverlayPage id="test" path="/one">
<div className="bg-blue-900 p-4">
<p>ONE</p>
<button
type="button"
onClick={() => {
router.navigate("/");
}}
>
back home
</button>
</div>
</OverlayPage>
<OverlayPage id="test" path="/two">
<div className="bg-blue-900 p-4">
<p>TWO</p>
<button
type="button"
onClick={() => {
router.navigate("/");
}}
>
back home
</button>
</div>
</OverlayPage>
</Overlay> </Overlay>
</div> </div>
</OverlayDisplay> </OverlayDisplay>

View File

@ -0,0 +1,23 @@
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
export interface OverlayTransition {
from: string;
to: string;
}
interface OverlayStore {
transition: null | OverlayTransition;
setTransition(newTrans: OverlayTransition | null): void;
}
export const useOverlayStore = create(
immer<OverlayStore>((set) => ({
transition: null,
setTransition(newTrans) {
set((s) => {
s.transition = newTrans;
});
},
}))
);