{
+ createRenderEffect(() => {
+ e.style.setProperty(
+ "--scaffold-topbar-height",
+ (topbarSize.height?.toString() ?? 0) + "px",
+ );
+ });
+
+ if (managed.ref) {
+ (managed.ref as (val: typeof e) => void)(e);
+ }
+ }}
+ {...rest}
+ >
-
+
{props.topbar}
- {props.fab}
+
+ {props.fab}
+
-
{
- createRenderEffect(() => {
- e.style.setProperty(
- "--scaffold-topbar-height",
- (topbarSize.height?.toString() ?? 0) + "px",
- );
- });
- if (managed.ref) {
- (managed.ref as (val: typeof e) => void)(e);
- }
- }}
- {...rest}
- >
- {managed.children}
-
+ {managed.children}
+
- {props.bottom}
+
+ {props.bottom}
+
- >
+
);
};
diff --git a/src/platform/A.tsx b/src/platform/A.tsx
new file mode 100644
index 0000000..b655dc9
--- /dev/null
+++ b/src/platform/A.tsx
@@ -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 ;
+};
+
+export default A;
diff --git a/src/platform/BackButton.tsx b/src/platform/BackButton.tsx
new file mode 100644
index 0000000..0c59876
--- /dev/null
+++ b/src/platform/BackButton.tsx
@@ -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;
+
+const BackButton: Component = (props) => {
+ const currentFrame = useCurrentFrame();
+ const { pop } = useNavigator();
+
+ const hasPrevSubPage = () => currentFrame().index > 1;
+
+ return (
+
+ }>
+
+
+
+ );
+};
+
+export default BackButton;
diff --git a/src/platform/StackedRouter.css b/src/platform/StackedRouter.css
new file mode 100644
index 0000000..714893c
--- /dev/null
+++ b/src/platform/StackedRouter.css
@@ -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;
+ }
+}
\ No newline at end of file
diff --git a/src/platform/StackedRouter.tsx b/src/platform/StackedRouter.tsx
new file mode 100644
index 0000000..8d489ae
--- /dev/null
+++ b/src/platform/StackedRouter.tsx
@@ -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
;
+
+export type StackFrame = {
+ path: string;
+ rootId: string;
+ state: unknown;
+ beforeShow?: (element: HTMLElement) => void;
+};
+
+export type NewFrameOptions = (T extends undefined
+ ? {
+ state?: T;
+ }
+ : { state: T }) & {
+ replace?: boolean;
+};
+
+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 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;
+};
+
+const CurrentFrameContext =
+ /* @__PURE__ */ createContext>>();
+
+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 = (oprops) => {
+ const [stack, mutStack] = createStore([] as StackFrame[], { name: "stack" });
+
+ const pushFrame = (path: string, opts?: Readonly>) =>
+ 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 (
+
+
+ {(frame, index) => {
+ const currentFrame = () => {
+ return {
+ index,
+ frame: frame(),
+ };
+ };
+
+ return (
+
+
+
+
+ }
+ >
+