first prototype of StackedRouter

This commit is contained in:
thislight 2024-11-16 20:04:55 +08:00
parent 607fa64c05
commit 0710aaf4f3
No known key found for this signature in database
GPG key ID: FCFE5192241CCD4E
21 changed files with 442 additions and 109 deletions

19
src/platform/A.tsx Normal file
View file

@ -0,0 +1,19 @@
import { type JSX } from "solid-js";
import { useNavigator } from "./StackedRouter";
function handleClick(
push: (name: string, state: unknown) => void,
event: MouseEvent & { currentTarget: HTMLAnchorElement },
) {
const target = event.currentTarget;
event.preventDefault();
event.stopPropagation();
push(target.href, { state: target.getAttribute("state") || undefined });
}
const A = (oprops: JSX.HTMLElementTags["a"]) => {
const { push } = useNavigator();
return <a onClick={[handleClick, push]} {...oprops}></a>;
};
export default A;

View file

@ -0,0 +1,24 @@
import type { IconButtonProps } from "@suid/material/IconButton";
import IconButton from "@suid/material/IconButton";
import { Show, type Component } from "solid-js";
import { useCurrentFrame, useNavigator } from "./StackedRouter";
import { ArrowBack, Close } from "@suid/icons-material";
export type BackButtonProps = Omit<IconButtonProps, "onClick" | "children">;
const BackButton: Component<BackButtonProps> = (props) => {
const currentFrame = useCurrentFrame();
const { pop } = useNavigator();
const hasPrevSubPage = () => currentFrame().index > 1;
return (
<IconButton onClick={[pop, 1]} {...props}>
<Show when={hasPrevSubPage()} fallback={<Close />}>
<ArrowBack />
</Show>
</IconButton>
);
};
export default BackButton;

View file

@ -0,0 +1,52 @@
.StackedPage {
container: StackedPage / size;
display: contents;
max-width: 100vw;
max-width: 100dvw;
}
dialog.StackedPage {
border: none;
position: fixed;
padding: 0;
overscroll-behavior: none;
width: 560px;
max-height: 100vh;
max-height: 100dvh;
background: none;
display: none;
contain: strict;
contain-intrinsic-size: auto 560px auto 100vh;
contain-intrinsic-size: auto 560px auto 100dvh;
content-visibility: auto;
box-shadow: var(--tutu-shadow-e16);
@media (min-width: 560px) {
& {
left: 50%;
transform: translateX(-50%);
}
}
@media (max-width: 560px) {
& {
width: 100vw;
width: 100dvw;
height: 100vh;
height: 100dvh;
contain-intrinsic-size: 100vw 100vh;
contain-intrinsic-size: 100dvw 100dvh;
}
}
&[open] {
display: contents;
}
&::backdrop {
background: none;
}
}

View file

@ -0,0 +1,228 @@
import { StaticRouter, type RouterProps } from "@solidjs/router";
import {
Component,
createContext,
createEffect,
createRenderEffect,
createUniqueId,
Index,
onMount,
Show,
untrack,
useContext,
type Accessor,
} from "solid-js";
import { createStore, unwrap } from "solid-js/store";
import { insert, render } from "solid-js/web";
import "./StackedRouter.css";
import { animateSlideInFromRight } from "./anim";
import { ANIM_CURVE_DECELERATION, ANIM_CURVE_STD } from "../material/theme";
export type StackedRouterProps = Omit<RouterProps, "url">;
export type StackFrame = {
path: string;
rootId: string;
state: unknown;
beforeShow?: (element: HTMLElement) => void;
};
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();
}
}
/**
* 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);
return frame;
});
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 = element.animate(
{
opacity: [0.5, 0],
},
{ easing: ANIM_CURVE_STD, duration: 220 },
);
animation.addEventListener("finish", () =>
mutStack((o) => o.toSpliced(o.length - depth, depth)),
);
});
} else {
mutStack((o) => {
return o.toSpliced(o.length - depth, depth);
});
}
});
/* createEffect(() => {
const length = stack.length;
console.debug("stack is changed", length, unwrap(stack));
}); */
createRenderEffect(() => {
if (stack.length === 0) {
pushFrame("/", undefined);
}
});
const onBeforeDialogMount = (element: HTMLDialogElement) => {
createEffect(() => {
requestAnimationFrame(() => {
element.showModal();
if (window.innerWidth <= 560) {
animateSlideInFromRight(element, { easing: ANIM_CURVE_DECELERATION });
} else {
element.animate(
{
opacity: [0.5, 1],
},
{ easing: ANIM_CURVE_STD, duration: 220 },
);
}
});
});
};
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;