import { StaticRouter, type RouterProps } from "@solidjs/router"; import { Component, createContext, createEffect, createRenderEffect, createUniqueId, Index, Show, untrack, useContext, type Accessor, } from "solid-js"; import { createStore, unwrap } from "solid-js/store"; import "./StackedRouter.css"; import { animateSlideInFromRight, animateSlideOutToRight } from "./anim"; import { ANIM_CURVE_DECELERATION, ANIM_CURVE_STD } from "../material/theme"; import { makeEventListener, } from "@solid-primitives/event-listener"; export type StackedRouterProps = Omit; export type StackFrame = { path: string; rootId: string; state: unknown; }; export type NewFrameOptions = (T extends undefined ? { state?: T; } : { state: T }) & { replace?: boolean; }; export type FramePusher = T[K] extends | undefined | any ? (path: K, state?: Readonly>) => Readonly : (path: K, state: Readonly>) => Readonly; export type Navigator> = { frames: readonly StackFrame[]; push: FramePusher; pop: (depth?: number) => void; }; const NavigatorContext = /* @__PURE__ */ createContext(); /** * Get the navigator of the {@link StackedRouter}. * * This function returns a {@link Navigator} without available * push guide. Push guide is a record type contains available * path and its state. If you need push guide, you may want to * define your own function (like `useAppNavigator`) and cast the * navigator to the type you need. */ export function useNavigator() { const navigator = useContext(NavigatorContext); if (!navigator) { throw new TypeError("not in available scope of StackedRouter"); } return navigator; } export type CurrentFrame = { index: number; frame: Readonly; }; const CurrentFrameContext = /* @__PURE__ */ createContext>>(); export function useCurrentFrame() { const frame = useContext(CurrentFrameContext); if (!frame) { throw new TypeError("not in available scope of StackedRouter"); } return frame; } function onDialogClick( onClose: () => void, event: MouseEvent & { currentTarget: HTMLDialogElement }, ) { if (event.target !== event.currentTarget) return; const rect = event.currentTarget.getBoundingClientRect(); const isNotInDialog = event.clientY < rect.top || event.clientY > rect.bottom || event.clientX < rect.left || event.clientX > rect.right; if (isNotInDialog) { onClose(); } } function animateClose(element: HTMLElement) { if (window.innerWidth <= 560) { return animateSlideOutToRight(element, { easing: ANIM_CURVE_DECELERATION }); } else { return element.animate( { opacity: [0.5, 0], }, { easing: ANIM_CURVE_STD, duration: 220 }, ); } } function animateOpen(element: HTMLElement) { if (window.innerWidth <= 560) { animateSlideInFromRight(element, { easing: ANIM_CURVE_DECELERATION }); } else { element.animate( { opacity: [0.5, 1], }, { easing: ANIM_CURVE_STD, duration: 220 }, ); } } /** * The router that stacks the pages. */ const StackedRouter: Component = (oprops) => { const [stack, mutStack] = createStore([] as StackFrame[], { name: "stack" }); const pushFrame = (path: string, opts?: Readonly>) => untrack(() => { const frame = { path, state: opts?.state, rootId: createUniqueId(), }; mutStack(opts?.replace ? stack.length - 1 : stack.length, frame); if (opts?.replace) { window.history.replaceState(unwrap(stack), "", path); } else { window.history.pushState(unwrap(stack), "", path); } return frame; }); const onlyPopFrame = (depth: number) => { mutStack((o) => o.toSpliced(o.length - depth, depth)); window.history.go(-depth); }; const popFrame = (depth: number = 1) => untrack(() => { if (import.meta.env.DEV) { if (depth < 0) { console.warn("the depth to pop should not < 0, now is", depth); } } if (stack.length > 1) { const lastFrame = stack[stack.length - 1]; const element = document.getElementById(lastFrame.rootId)!; requestAnimationFrame(() => { const animation = animateClose(element); animation.addEventListener("finish", () => onlyPopFrame(depth)); }); } else { onlyPopFrame(depth); } }); /* createEffect(() => { const length = stack.length; console.debug("stack is changed", length, unwrap(stack)); }); */ createRenderEffect(() => { if (stack.length === 0) { pushFrame(window.location.pathname); } }); createRenderEffect(() => { makeEventListener(window, "popstate", (event) => { if (event.state && stack.length !== event.state.length) { mutStack(event.state); } }); }); const onBeforeDialogMount = (element: HTMLDialogElement) => { createEffect(() => { requestAnimationFrame(() => { element.showModal(); animateOpen(element); }); }); }; return ( {(frame, index) => { const currentFrame = () => { return { index, frame: frame(), }; }; return ( } > ); }} ); }; export default StackedRouter;