import { StaticRouter, type RouterProps } from "@solidjs/router"; import { Component, createContext, createMemo, createRenderEffect, createUniqueId, Index, onMount, Show, untrack, useContext, onCleanup, 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"; import { useWindowSize } from "@solid-primitives/resize-observer"; export type StackedRouterProps = Omit; export type StackFrame = { path: string; rootId: string; state: unknown; animateOpen?: (element: HTMLElement) => Animation; animateClose?: (element: HTMLElement) => Animation; }; export type NewFrameOptions = (T extends undefined ? { state?: T; } : { state: T }) & { /** * The new frame should replace the current frame or all the stack. */ replace?: boolean | "all"; /** * The animatedOpen phase of the life cycle. * * You can use this hook to animate the opening * of the frame. In this phase, the frame content is created * and is mounted to the document. * * You must return an {@link Animation}. This function must be * without side effects. This phase is ended after the {@link Animation} * finished. */ animateOpen?: StackFrame["animateOpen"]; /** * The animatedClose phase of the life cycle. * * You can use this hook to animate the closing of the frame. * In this phase, the frame content is still mounted in the * document and will be unmounted after this phase. * * You must return an {@link Animation}. This function must be * without side effects. This phase is ended after the * {@link Animation} finished. */ animateClose?: StackFrame["animateClose"]; }; 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 possible navigator of the {@link StackedRouter}. * * @see useNavigator for the navigator usage. */ export function useMaybeNavigator() { return useContext(NavigatorContext); } /** * 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. * * @see {@link useMaybeNavigator} if you are not sure you are under a {@link StackedRouter}. */ export function useNavigator() { const navigator = useMaybeNavigator(); if (!navigator) { throw new TypeError("not in available scope of StackedRouter"); } return navigator; } export type CurrentFrame = { index: number; frame: Readonly; }; const CurrentFrameContext = /* @__PURE__ */ createContext>>(); /** * Return the current, if possible. * * @see {@link useCurrentFrame} asserts the frame exists */ export function useMaybeCurrentFrame() { return useContext(CurrentFrameContext); } /** * Return the current frame, assert the frame exists. * * @see {@link useMaybeCurrentFrame} if you are not sure you are under a {@link StackedRouter}. */ export function useCurrentFrame() { const frame = useMaybeCurrentFrame(); if (!frame) { throw new TypeError("not in available scope of StackedRouter"); } return frame; } /** * Return an accessor of is current frame is suspended. * * A suspended frame is the one not on the top. "Suspended" * is the description of a certain situtation, not in the life cycle * of a frame. * * If this is not called under a {@link StackedRouter}, it always * returns `false`. */ export function useIsFrameSuspended() { const { frames } = useMaybeNavigator() || {}; if (typeof frames === "undefined") { return () => false; } const thisFrame = useCurrentFrame(); return () => { const idx = thisFrame().index; return frames.length - 1 > idx; }; } 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) { return animateSlideInFromRight(element, { easing: ANIM_CURVE_DECELERATION, }); } else { return element.animate( { opacity: [0.5, 1], }, { easing: ANIM_CURVE_STD, duration: 220 }, ); } } function serializableStack(stack: readonly StackFrame[]) { const frames = unwrap(stack); return frames.map((fr) => { return fr.animateClose || fr.animateOpen ? { path: fr.path, rootId: fr.rootId, state: fr.state, } : fr; }); } function isNotInIOSSwipeToBackArea(x: number) { return ( (x > 22 && x < window.innerWidth - 22) || (x < -22 && x > window.innerWidth + 22) ); } function onEntryTouchStart(event: TouchEvent) { if (event.touches.length !== 1) { return; } const [fig0] = event.touches; if (isNotInIOSSwipeToBackArea(fig0.clientX)) { return; } event.preventDefault(); } /** * This function contains the state for swipe to back. * * @returns the props for dialogs to feature swipe to back. */ function createManagedSwipeToBack( stack: readonly Readonly[], onlyPopFrame: (depth: number) => void, ) { let reenterableAnimation: Animation | undefined; let origWidth = 0, origFigX = 0, origFigY = 0; const resetAnimation = () => { reenterableAnimation = undefined; }; const onDialogTouchStart = ( event: TouchEvent & { currentTarget: HTMLDialogElement }, ) => { if (event.touches.length !== 1) { return; } event.stopPropagation(); const [fig0] = event.touches; const { width } = event.currentTarget.getBoundingClientRect(); origWidth = width; origFigX = fig0.clientX; origFigY = fig0.clientY; if (isNotInIOSSwipeToBackArea(fig0.clientX)) { return; } // Prevent the default swipe to back/forward on iOS event.preventDefault(); }; let animationProgressUpdateReleased = true; let nextAnimationProgress = 0; const updateAnimationProgress = () => { try { if (!reenterableAnimation) return; const { activeDuration, delay } = reenterableAnimation.effect!.getComputedTiming(); const totalTime = (delay || 0) + Number(activeDuration); reenterableAnimation.currentTime = totalTime * nextAnimationProgress; } finally { animationProgressUpdateReleased = true; } }; const onDialogTouchMove = ( event: TouchEvent & { currentTarget: HTMLDialogElement }, ) => { if (event.touches.length !== 1) { if (reenterableAnimation) { reenterableAnimation.reverse(); reenterableAnimation.play(); } } const [fig0] = event.touches; const ofsX = fig0.clientX - origFigX; if (!reenterableAnimation) { if (!(ofsX > 22) || !(Math.abs(fig0.clientY - origFigY) < 44)) { return; } const lastFr = stack[stack.length - 1]; const createAnimation = lastFr.animateClose ?? animateClose; reenterableAnimation = createAnimation(event.currentTarget); reenterableAnimation.pause(); reenterableAnimation.addEventListener("finish", resetAnimation); reenterableAnimation.addEventListener("cancel", resetAnimation); } event.preventDefault(); event.stopPropagation(); nextAnimationProgress = ofsX / origWidth / window.devicePixelRatio; if (animationProgressUpdateReleased) { animationProgressUpdateReleased = false; requestAnimationFrame(updateAnimationProgress); } }; const onDialogTouchEnd = (event: TouchEvent) => { if (!reenterableAnimation) return; event.preventDefault(); event.stopPropagation(); const { activeDuration, delay } = reenterableAnimation.effect!.getComputedTiming(); const totalTime = (delay || 0) + Number(activeDuration); if (Number(reenterableAnimation.currentTime) / totalTime > 0.1) { reenterableAnimation.addEventListener("finish", () => { onlyPopFrame(1); }); reenterableAnimation.play(); } else { reenterableAnimation.cancel(); } }; const onDialogTouchCancel = (event: TouchEvent) => { if (!reenterableAnimation) return; event.preventDefault(); event.stopPropagation(); reenterableAnimation.cancel(); }; return { "on:touchstart": onDialogTouchStart, "on:touchmove": onDialogTouchMove, "on:touchend": onDialogTouchEnd, "on:touchcancel": onDialogTouchCancel, }; } function animateUntil( stepfn: (onCreated: (animation: Animation) => void) => void, ) { const execStep = () => { requestAnimationFrame(() => { stepfn((step) => { step.addEventListener("finish", () => { execStep(); }); }); }); }; execStep(); } /** * The cache key of saved stack for hot reload. * * We could not use symbols because every time the hot reload the `Symbol()` * call creates a new symbol. */ const $StackedRouterSavedStack = "$StackedRouterSavedStack"; /** * The router that stacks the pages. * * **Routes** The router accepts the {@link RouterProps} excluding the "url" field. * You can seamlessly use the `` from `@solidjs/router`. * * Be advised that this component is not a drop-in replacement of that router. * These primitives from `@solidjs/router` won't work correctly: * * - `` component - use ~platform/A instead * - `useLocation()` - see {@link useCurrentFrame} * - `useNavigate()` - see {@link useNavigator} * * The other primitives may work, as long as they don't rely on the global location. * This component uses `@solidjs/router` {@link StaticRouter} to route. * * **Injecting Safe Area Insets** The router calculate correct * `--safe-area-inset-left` and `--safe-area-inset-right` from the window * width and `--safe-area-inset-*` from the :root element. That means * the injected insets do not reflects the overrides that are not on the :root. * * The recalculation is only performed when the window size changed. * * **Navigation Animation** The router provides default animation for * navigation. * * If the default animation does not met your requirement, * this component is also intergated with Web Animation API. * You can provide {@link NewFrameOptions.animateOpen} and * {@link NewFrameOptions.animateClose} to define custom animation. * * **Swipe to back** For the subpages (the pages stacked on the entry), * swipe to back gesture is provided for user experience. * * Navigation animations (even the custom ones) will be played during * swipe to back, please keep in mind when designing animations. * * The iOS default gesture is blocked on all pages. */ const StackedRouter: Component = (oprops) => { const [stack, mutStack] = createStore([] as StackFrame[], { name: "stack" }); const windowSize = useWindowSize(); if (import.meta.hot) { const saveStack = () => { import.meta.hot!.data[$StackedRouterSavedStack] = unwrap(stack); console.debug("stack saved"); }; import.meta.hot.on("vite:beforeUpdate", saveStack); onCleanup(() => import.meta.hot!.off("vite:beforeUpdate", saveStack)); const loadStack = () => { const savedStack = import.meta.hot!.data[$StackedRouterSavedStack]; if (savedStack) { mutStack(savedStack); console.debug("stack loaded"); } delete import.meta.hot!.data[$StackedRouterSavedStack]; }; createRenderEffect(() => { loadStack() }); } const pushFrame = (path: string, opts?: Readonly>) => untrack(() => { const frame = { path, state: opts?.state, rootId: createUniqueId(), animateOpen: opts?.animateOpen, animateClose: opts?.animateClose, }; const replace = opts?.replace; if (replace === "all") { mutStack([frame]); } else { mutStack(replace ? stack.length - 1 : stack.length, frame); } const savedStack = serializableStack(stack); if (replace) { window.history.replaceState(savedStack, "", path); } else { window.history.pushState(savedStack, "", path); } return frame; }); const onlyPopFrameOnStack = (depth: number) => { mutStack((o) => o.toSpliced(o.length - depth, depth)); }; const onlyPopFrame = (depth: number) => { onlyPopFrameOnStack(depth); window.history.go(-depth); }; const animatePopOneFrame = (onCreated: (animation: Animation) => void) => { const lastFrame = stack[stack.length - 1]; const element = document.getElementById( lastFrame.rootId, )! as HTMLDialogElement; const createAnimation = lastFrame.animateClose ?? animateClose; element.classList.add("animating"); const onNavAnimEnd = () => { element.classList.remove("animating"); }; requestAnimationFrame(() => { const animation = createAnimation(element); animation.addEventListener("finish", onNavAnimEnd); animation.addEventListener("cancel", onNavAnimEnd); onCreated(animation); }); }; 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) { let count = depth; animateUntil((created) => { if (count > 0) { animatePopOneFrame((a) => { a.addEventListener("finish", () => onlyPopFrame(1)); created(a); }); } count--; }); } else { onlyPopFrame(1); } }); createRenderEffect(() => { if (stack.length === 0) { pushFrame(window.location.pathname, { replace: "all", }); } }); createRenderEffect(() => { makeEventListener(window, "popstate", (event) => { if (!event.state) return; // TODO: verify the stack in state and handling forwards if (stack.length === 0) { mutStack(event.state || []); } else if (stack.length > event.state.length) { let count = stack.length - event.state.length; animateUntil((created) => { if (count > 0) { animatePopOneFrame((a) => { a.addEventListener("finish", () => { onlyPopFrameOnStack(1); created(a); }); }); } count--; }); } }); }); const onBeforeDialogMount = (element: HTMLDialogElement) => { onMount(() => { const lastFr = untrack(() => stack[stack.length - 1]); const createAnimation = lastFr.animateOpen ?? animateOpen; requestAnimationFrame(() => { element.showModal(); element.classList.add("animating"); const animation = createAnimation(element); animation.addEventListener("finish", () => element.classList.remove("animating"), ); }); }); }; const subInsets = createMemo(() => { const SUBPAGE_MAX_WIDTH = 560; const { width } = windowSize; if (width <= SUBPAGE_MAX_WIDTH) { // page width = 100vw, use the inset directly return {}; } const computedStyle = window.getComputedStyle( document.querySelector(":root")!, ); const oinsetLeft = computedStyle .getPropertyValue("--safe-area-inset-left") .split("px", 1)[0]; const oinsetRight = computedStyle .getPropertyValue("--safe-area-inset-right") .split("px", 1)[0]; const left = Number(oinsetLeft), right = Number(oinsetRight.slice(0, oinsetRight.length - 2)); const totalWidth = SUBPAGE_MAX_WIDTH + left + right; if (width >= totalWidth) { return { "--safe-area-inset-left": "0px", "--safe-area-inset-right": "0px", }; } const ofs = (totalWidth - width) / 2; return { "--safe-area-inset-left": `${Math.max(left - ofs, 0)}px`, "--safe-area-inset-right": `${Math.max(right - ofs, 0)}px`, }; }); const swipeToBackProps = createManagedSwipeToBack(stack, onlyPopFrame); return ( {(frame, index) => { const currentFrame = () => { return { index, frame: frame(), }; }; return ( } > ); }} ); }; export default StackedRouter;