tutu/src/platform/StackedRouter.tsx

255 lines
6.5 KiB
TypeScript

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<RouterProps, "url">;
export type StackFrame = {
path: string;
rootId: string;
state: unknown;
};
export type NewFrameOptions<T> = (T extends undefined
? {
state?: T;
}
: { state: T }) & {
replace?: boolean;
};
export type FramePusher<T, K extends keyof T = keyof T> = T[K] extends
| undefined
| any
? (path: K, state?: Readonly<NewFrameOptions<T[K]>>) => Readonly<StackFrame>
: (path: K, state: Readonly<NewFrameOptions<T[K]>>) => Readonly<StackFrame>;
export type Navigator<PushGuide = Record<string, any>> = {
frames: readonly StackFrame[];
push: FramePusher<PushGuide>;
pop: (depth?: number) => void;
};
const NavigatorContext = /* @__PURE__ */ createContext<Navigator>();
/**
* 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<StackFrame>;
};
const CurrentFrameContext =
/* @__PURE__ */ createContext<Accessor<Readonly<CurrentFrame>>>();
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<StackedRouterProps> = (oprops) => {
const [stack, mutStack] = createStore([] as StackFrame[], { name: "stack" });
const pushFrame = (path: string, opts?: Readonly<NewFrameOptions<any>>) =>
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 (
<NavigatorContext.Provider
value={{
push: pushFrame,
pop: popFrame,
frames: stack,
}}
>
<Index each={stack}>
{(frame, index) => {
const currentFrame = () => {
return {
index,
frame: frame(),
};
};
return (
<CurrentFrameContext.Provider value={currentFrame}>
<Show
when={index !== 0}
fallback={
<div
class="StackedPage"
id={frame().rootId}
role="presentation"
>
<StaticRouter url={frame().path} {...oprops} />
</div>
}
>
<dialog
ref={onBeforeDialogMount}
class="StackedPage"
onCancel={[popFrame, 1]}
onClick={[onDialogClick, popFrame]}
id={frame().rootId}
>
<StaticRouter url={frame().path} {...oprops} />
</dialog>
</Show>
</CurrentFrameContext.Provider>
);
}}
</Index>
</NavigatorContext.Provider>
);
};
export default StackedRouter;