255 lines
6.5 KiB
TypeScript
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;
|