first prototype of StackedRouter
This commit is contained in:
parent
607fa64c05
commit
0710aaf4f3
21 changed files with 442 additions and 109 deletions
19
src/platform/A.tsx
Normal file
19
src/platform/A.tsx
Normal 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;
|
24
src/platform/BackButton.tsx
Normal file
24
src/platform/BackButton.tsx
Normal 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;
|
52
src/platform/StackedRouter.css
Normal file
52
src/platform/StackedRouter.css
Normal 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;
|
||||
}
|
||||
}
|
228
src/platform/StackedRouter.tsx
Normal file
228
src/platform/StackedRouter.tsx
Normal 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;
|
Loading…
Add table
Add a link
Reference in a new issue