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 { Transition } from "@/components/Transition";
import { useOverlayRouter } from "@/hooks/useOverlayRouter";
import { useInternalOverlayRouter } from "@/hooks/useOverlayRouter";
export interface OverlayProps {
id: string;
@ -16,11 +16,21 @@ export function OverlayDisplay(props: { children: ReactNode }) {
}
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 ref = useRef<HTMLDivElement>(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(() => {
function listen(e: MouseEvent) {
target.current = e.target as Element;

View File

@ -3,32 +3,37 @@ import { ReactNode } from "react";
import { Transition } from "@/components/Transition";
import { useIsMobile } from "@/hooks/useIsMobile";
import { useInternalOverlayRouter } from "@/hooks/useOverlayRouter";
interface Props {
id: string;
path: string;
children?: ReactNode;
show?: boolean;
className?: string;
height?: number;
width?: number;
active?: boolean; // true if a child view is loaded
}
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 width = !isMobile ? `${props.width}px` : "100%";
return (
<Transition
animation={props.active ? "slide-full-left" : "slide-full-right"}
animation={backwards ? "slide-full-left" : "slide-full-right"}
className="absolute inset-0"
durationClass="duration-[400ms]"
show={props.show}
show={show}
>
<div
className={classNames([
props.className,
"grid grid-rows-[auto,minmax(0,1fr)]",
])}
data-floating-page={props.show ? "true" : undefined}
data-floating-page={show ? "true" : undefined}
style={{
height: props.height ? `${props.height}px` : undefined,
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 routeParts = (route ?? "").split("/").filter((v) => v.length > 0);
const routerActive = routeParts.length > 0 && routeParts[0] === id;
const transition = useOverlayStore((s) => s.transition);
const setTransition = useOverlayStore((s) => s.setTransition);
const routerActive = !!route && route.startsWith(`/${id}`);
function navigate(path: string) {
const newRoute = [id, ...path.split("/").filter((v) => v.length > 0)];
setRoute(newRoute.join("/"));
const oldRoute = route;
const newRoute = joinPath(splitPath(path, id));
setTransition({
from: oldRoute ?? "/",
to: newRoute,
});
setRoute(newRoute);
}
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 showBackwardsTransition(path: string) {
if (!transition) return false;
const current = joinPath(splitPath(path, id));
if (current === transition.to && transition.from.startsWith(transition.to))
return true;
if (
current === transition.from &&
transition.to.startsWith(transition.from)
)
return true;
return false;
}
function isCurrentPage(page: string) {
return routerActive && route === `/${id}${page}`;
}
function isLoaded(page: string) {
if (page === "/") return true;
return route.includes(page);
function isCurrentPage(path: string) {
return routerActive && route === joinPath(splitPath(path, id));
}
function isOverlayActive() {
return routerActive;
}
function pageProps(page: string) {
return {
show: isCurrentPage(page),
active: isActive(page),
};
}
function close() {
const close = useCallback(() => {
setTransition(null);
setRoute(null);
}
}, [setRoute, setTransition]);
function open() {
const open = useCallback(() => {
setTransition(null);
setRoute(`/${id}`);
}
}, [id, setRoute, setTransition]);
return {
showBackwardsTransition,
isCurrentPage,
isOverlayActive,
navigate,
close,
isLoaded,
isCurrentPage,
pageProps,
isActive,
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;
}
export function useQueryParam(param: string) {
export function useQueryParam(
param: string
): [string | null, (a: string | null) => void] {
const params = useQueryParams();
const location = useLocation();
const router = useHistory();
const currentValue = params[param];
const currentValue = params[param] ?? null;
const set = useCallback(
(value: string | null) => {
@ -34,5 +36,5 @@ export function useQueryParam(param: string) {
[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
export default function TestView() {
const router = useOverlayRouter("test");
const pages = ["", "/one", "/two"];
return (
<OverlayDisplay>
@ -19,21 +18,57 @@ export default function TestView() {
>
Open
</button>
<button
type="button"
onClick={() => {
router.navigate(pages[Math.floor(pages.length * Math.random())]);
}}
>
random page
</button>
<OverlayAnchor id="test">
<div className="h-20 w-20 bg-white" />
</OverlayAnchor>
<Overlay id="test">
<OverlayPage {...router.pageProps("")}>Home</OverlayPage>
<OverlayPage {...router.pageProps("/one")}>Page one</OverlayPage>
<OverlayPage {...router.pageProps("/two")}>Page two</OverlayPage>
<OverlayPage id="test" path="/">
<div className="bg-blue-900 p-4">
<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>
</div>
</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;
});
},
}))
);