@vkma.dev/router 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/core/bridge-like.d.ts +25 -0
- package/dist/core/bridge-like.d.ts.map +1 -0
- package/dist/core/bridge-like.js +13 -0
- package/dist/core/bridge-like.js.map +1 -0
- package/dist/core/bridge-sync.d.ts +37 -0
- package/dist/core/bridge-sync.d.ts.map +1 -0
- package/dist/core/bridge-sync.js +58 -0
- package/dist/core/bridge-sync.js.map +1 -0
- package/dist/core/hash-params.d.ts +40 -0
- package/dist/core/hash-params.d.ts.map +1 -0
- package/dist/core/hash-params.js +60 -0
- package/dist/core/hash-params.js.map +1 -0
- package/dist/core/loose-route.js +9 -0
- package/dist/core/loose-route.js.map +1 -0
- package/dist/core/panels-stack.d.ts +28 -0
- package/dist/core/panels-stack.d.ts.map +1 -0
- package/dist/core/panels-stack.js +60 -0
- package/dist/core/panels-stack.js.map +1 -0
- package/dist/core/state-keys.d.ts +10 -0
- package/dist/core/state-keys.d.ts.map +1 -0
- package/dist/core/state-keys.js +21 -0
- package/dist/core/state-keys.js.map +1 -0
- package/dist/core/swipe-back.d.ts +25 -0
- package/dist/core/swipe-back.d.ts.map +1 -0
- package/dist/core/swipe-back.js +53 -0
- package/dist/core/swipe-back.js.map +1 -0
- package/dist/core/vk-history.d.ts +41 -0
- package/dist/core/vk-history.d.ts.map +1 -0
- package/dist/core/vk-history.js +115 -0
- package/dist/core/vk-history.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/preset/app.d.ts +35 -0
- package/dist/preset/app.d.ts.map +1 -0
- package/dist/preset/app.js +134 -0
- package/dist/preset/app.js.map +1 -0
- package/dist/preset/builders.d.ts +209 -0
- package/dist/preset/builders.d.ts.map +1 -0
- package/dist/preset/builders.js +209 -0
- package/dist/preset/builders.js.map +1 -0
- package/dist/preset/define-panel.d.ts +77 -0
- package/dist/preset/define-panel.d.ts.map +1 -0
- package/dist/preset/define-panel.js +42 -0
- package/dist/preset/define-panel.js.map +1 -0
- package/dist/preset/define-view.d.ts +42 -0
- package/dist/preset/define-view.d.ts.map +1 -0
- package/dist/preset/define-view.js +43 -0
- package/dist/preset/define-view.js.map +1 -0
- package/dist/preset/inflate.d.ts +17 -0
- package/dist/preset/inflate.d.ts.map +1 -0
- package/dist/preset/inflate.js +40 -0
- package/dist/preset/inflate.js.map +1 -0
- package/dist/preset/navigator.d.ts +75 -0
- package/dist/preset/navigator.d.ts.map +1 -0
- package/dist/preset/navigator.js +116 -0
- package/dist/preset/navigator.js.map +1 -0
- package/dist/preset.d.ts +17 -0
- package/dist/preset.js +16 -0
- package/dist/react/hooks.d.ts +69 -0
- package/dist/react/hooks.d.ts.map +1 -0
- package/dist/react/hooks.js +101 -0
- package/dist/react/hooks.js.map +1 -0
- package/dist/react.d.ts +2 -0
- package/dist/react.js +1 -0
- package/dist/vkui/choreography.js +59 -0
- package/dist/vkui/choreography.js.map +1 -0
- package/dist/vkui/modals.d.ts +49 -0
- package/dist/vkui/modals.d.ts.map +1 -0
- package/dist/vkui/modals.js +116 -0
- package/dist/vkui/modals.js.map +1 -0
- package/dist/vkui/not-found.d.ts +9 -0
- package/dist/vkui/not-found.d.ts.map +1 -0
- package/dist/vkui/not-found.js +23 -0
- package/dist/vkui/not-found.js.map +1 -0
- package/dist/vkui/popouts.d.ts +34 -0
- package/dist/vkui/popouts.d.ts.map +1 -0
- package/dist/vkui/popouts.js +69 -0
- package/dist/vkui/popouts.js.map +1 -0
- package/dist/vkui/vk-view.d.ts +26 -0
- package/dist/vkui/vk-view.d.ts.map +1 -0
- package/dist/vkui/vk-view.js +81 -0
- package/dist/vkui/vk-view.js.map +1 -0
- package/dist/vkui.d.ts +5 -0
- package/dist/vkui.js +5 -0
- package/package.json +70 -3
- package/src/core/bridge-like.ts +25 -0
- package/src/core/bridge-sync.ts +95 -0
- package/src/core/hash-params.ts +73 -0
- package/src/core/loose-route.ts +17 -0
- package/src/core/panels-stack.ts +85 -0
- package/src/core/state-keys.ts +39 -0
- package/src/core/swipe-back.ts +80 -0
- package/src/core/vk-history.ts +146 -0
- package/src/index.ts +30 -0
- package/src/preset/app.tsx +192 -0
- package/src/preset/builders.tsx +566 -0
- package/src/preset/define-panel.ts +109 -0
- package/src/preset/define-view.ts +73 -0
- package/src/preset/inflate.ts +57 -0
- package/src/preset/navigator.ts +250 -0
- package/src/preset.ts +51 -0
- package/src/react/hooks.ts +168 -0
- package/src/react.ts +11 -0
- package/src/vkui/choreography.ts +105 -0
- package/src/vkui/modals.tsx +216 -0
- package/src/vkui/not-found.tsx +21 -0
- package/src/vkui/popouts.tsx +106 -0
- package/src/vkui/vk-view.tsx +147 -0
- package/src/vkui.ts +17 -0
- package/README.md +0 -3
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { LooseRouteOptions, PanelLazy, PanelOptions } from "./builders.ts";
|
|
2
|
+
import { definePanel, type PanelDefinition } from "./define-panel.ts";
|
|
3
|
+
|
|
4
|
+
/** Brand distinguishing a {@link ViewDefinition} from raw view options. */
|
|
5
|
+
export const VIEW_DEFINITION = "__vkmaView" as const;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A view hub: the ONE place a view key is written. Created by
|
|
9
|
+
* {@link defineView}; its `panel()` method is the view-scoped
|
|
10
|
+
* `definePanel` — it creates the definition and registers it into the
|
|
11
|
+
* view's registry in call order (the order is what VKUI View children get,
|
|
12
|
+
* i.e. the push/pop animation direction).
|
|
13
|
+
*/
|
|
14
|
+
export interface ViewDefinition<V extends string = string> {
|
|
15
|
+
readonly [VIEW_DEFINITION]: true;
|
|
16
|
+
/** The view (story) namespace and route id segment (`view:${view}`). */
|
|
17
|
+
readonly view: V;
|
|
18
|
+
/** Definitions registered by `panel()`, keyed by panel path. */
|
|
19
|
+
readonly panels: Record<string, PanelDefinition>;
|
|
20
|
+
/** Optional view-level route options passthrough (the view is a pathless layout). */
|
|
21
|
+
readonly options?: LooseRouteOptions;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ViewScope<V extends string = string> {
|
|
25
|
+
/** View-scoped `definePanel` — the view argument is pre-bound. */
|
|
26
|
+
panel<const P extends string, O extends PanelOptions = PanelOptions>(
|
|
27
|
+
path: P,
|
|
28
|
+
options: O,
|
|
29
|
+
lazy?: PanelLazy,
|
|
30
|
+
): PanelDefinition<V, P, O>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Declare a view hub and its panel factory:
|
|
35
|
+
*
|
|
36
|
+
* ```ts
|
|
37
|
+
* export const feedView = defineView("feed");
|
|
38
|
+
*
|
|
39
|
+
* export const feedPanel = feedView.panel("feed", { loader: ... }, () => import("./feed.ui"));
|
|
40
|
+
* export const postPanel = feedView.panel("post/$id", { ... }, () => import("./post.ui"));
|
|
41
|
+
* ```
|
|
42
|
+
*
|
|
43
|
+
* The view key is written exactly once; `createApp` accepts the hub
|
|
44
|
+
* directly (`views: [feedView, ...]`). Duplicate view keys are a dev-time
|
|
45
|
+
* console error — a view must not sprawl across files.
|
|
46
|
+
*/
|
|
47
|
+
export function defineView<const V extends string>(
|
|
48
|
+
view: V,
|
|
49
|
+
options?: LooseRouteOptions,
|
|
50
|
+
): ViewDefinition<V> & ViewScope<V> {
|
|
51
|
+
const usedPanelPaths = new Set<string>();
|
|
52
|
+
const panels: Record<string, PanelDefinition> = {};
|
|
53
|
+
const definition: ViewDefinition<V> = {
|
|
54
|
+
[VIEW_DEFINITION]: true,
|
|
55
|
+
view,
|
|
56
|
+
panels,
|
|
57
|
+
...(options !== undefined ? { options } : {}),
|
|
58
|
+
};
|
|
59
|
+
const scope: ViewScope<V> = {
|
|
60
|
+
panel(path, panelOptions, lazy) {
|
|
61
|
+
if (usedPanelPaths.has(path)) {
|
|
62
|
+
console.error(
|
|
63
|
+
`[vkma-router] duplicate panel path "${path}" in view "${view}"; panels within a view must have distinct paths`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
usedPanelPaths.add(path);
|
|
67
|
+
const declared = definePanel(view, path, panelOptions, lazy);
|
|
68
|
+
panels[path] = declared;
|
|
69
|
+
return declared;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
return { ...definition, ...scope };
|
|
73
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inflate: a synthetic parent chain after a deep link. Deep-linked to the
|
|
3
|
+
* 4th panel? Back should walk the natural flow (panel 1 → 2 → 3 → 4)
|
|
4
|
+
* instead of closing the app. The chain is auto-derived from the view's
|
|
5
|
+
* panel declaration order (declaration order IS the navigation flow — the
|
|
6
|
+
* same convention VKUI animations rely on), replacing the old router's
|
|
7
|
+
* manual `hierarchy` config.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { PanelHandle } from "../preset/builders.tsx";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Build the chain of hrefs to replay BEFORE the target location.
|
|
14
|
+
*
|
|
15
|
+
* @param orderedPanels panels of the target's view, in declaration order
|
|
16
|
+
* @param targetRouteId route id of the deep-linked panel
|
|
17
|
+
* @param params params of the deep-linked location (fill intermediate
|
|
18
|
+
* panels' path params; a panel whose params cannot be filled
|
|
19
|
+
* is skipped from the chain)
|
|
20
|
+
* @returns hrefs excluding the target (the target is already the current
|
|
21
|
+
* history entry — replay replaces the first chain entry, pushes
|
|
22
|
+
* the rest, then re-pushes the target href)
|
|
23
|
+
*/
|
|
24
|
+
export function deriveInflateChain(
|
|
25
|
+
orderedPanels: PanelHandle[],
|
|
26
|
+
targetRouteId: string,
|
|
27
|
+
params: Record<string, string>,
|
|
28
|
+
): string[] {
|
|
29
|
+
const targetIndex = orderedPanels.findIndex(panel => panel.routeId === targetRouteId);
|
|
30
|
+
if (targetIndex <= 0) {
|
|
31
|
+
return []; // first panel of the view (or unknown) — nothing to inflate
|
|
32
|
+
}
|
|
33
|
+
const chain: string[] = [];
|
|
34
|
+
for (const panel of orderedPanels.slice(0, targetIndex)) {
|
|
35
|
+
const interpolated = interpolatePath(panel.path, params);
|
|
36
|
+
if (interpolated !== null) {
|
|
37
|
+
chain.push(interpolated); // a required param cannot be derived — skip the step
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return chain;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Fill a route path's `$param` segments from the deep link's params.
|
|
45
|
+
* Returns null when a required param cannot be derived (the step is
|
|
46
|
+
* unrepresentable as a URL and must be skipped).
|
|
47
|
+
*/
|
|
48
|
+
export function interpolatePath(path: string, params: Record<string, string>): string | null {
|
|
49
|
+
const interpolated = path.replace(/\$([A-Za-z0-9_]+)/g, (segment, key: string) => {
|
|
50
|
+
const value = params[key];
|
|
51
|
+
return value !== undefined ? value : segment;
|
|
52
|
+
});
|
|
53
|
+
if (interpolated.includes("$")) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
return interpolated.startsWith("/") ? interpolated : `/${interpolated}`;
|
|
57
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useNavigate,
|
|
3
|
+
useRouter,
|
|
4
|
+
useRouterState,
|
|
5
|
+
type NavigateOptions,
|
|
6
|
+
} from "@tanstack/react-router";
|
|
7
|
+
import { looseRouteOf } from "../core/loose-route.ts";
|
|
8
|
+
import { MODAL_STATE_KEY } from "../core/state-keys.ts";
|
|
9
|
+
import { useHidePopout, useShowPopout } from "../vkui/popouts.tsx";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The parity navigator over TanStack primitives: push/replace/back/
|
|
13
|
+
* backToFirst/showModal/hideModal/showPopout/hidePopout. Modal→modal
|
|
14
|
+
* navigation REPLACES the entry (the host closes one modal before opening
|
|
15
|
+
* the next — there is no modal stack); back from a modal returns to its
|
|
16
|
+
* panel. `push`/`replace`/`showModal` accept full TanStack NavigateOptions
|
|
17
|
+
* — typed by the registered route tree.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface VkNavigator {
|
|
21
|
+
push<TParams extends Record<string, string>>(
|
|
22
|
+
page: PanelPage<TParams>,
|
|
23
|
+
params: TParams,
|
|
24
|
+
options?: Omit<NavigateOptions, "to" | "params">,
|
|
25
|
+
): Promise<void>;
|
|
26
|
+
push(options: NavigateOptions): Promise<void>;
|
|
27
|
+
replace<TParams extends Record<string, string>>(
|
|
28
|
+
page: PanelPage<TParams>,
|
|
29
|
+
params: TParams,
|
|
30
|
+
options?: Omit<NavigateOptions, "to" | "params">,
|
|
31
|
+
): Promise<void>;
|
|
32
|
+
replace(options: NavigateOptions): Promise<void>;
|
|
33
|
+
back(): void;
|
|
34
|
+
/** Pop all the way to the first history entry. */
|
|
35
|
+
backToFirst(): void;
|
|
36
|
+
/** Open a modal route; replaces the entry if another modal is open.
|
|
37
|
+
* `props` (serializable) land in history.state and are read by
|
|
38
|
+
* `useModalProps()` inside the modal component. */
|
|
39
|
+
showModal(options: NavigateOptions & { props?: unknown }): Promise<void>;
|
|
40
|
+
/** Close the current modal (back, or replace to the panel on first entry). */
|
|
41
|
+
hideModal(): void;
|
|
42
|
+
/**
|
|
43
|
+
* Open a global (`?modal=<id>`) modal from any panel, masking the URL so
|
|
44
|
+
* the visible path stays at the panel. Back closes it.
|
|
45
|
+
*/
|
|
46
|
+
showGlobalModal(
|
|
47
|
+
id: string,
|
|
48
|
+
options?: { state?: Record<string, unknown>; props?: unknown },
|
|
49
|
+
): Promise<void>;
|
|
50
|
+
showPopout(id: string, props?: Record<string, unknown>): void;
|
|
51
|
+
hidePopout(): void;
|
|
52
|
+
/**
|
|
53
|
+
* Block navigation while `onLeave` returns true (old router's
|
|
54
|
+
* `RouteNavigator.block`). The callback receives the current and the
|
|
55
|
+
* next location plus the action — PUSH/REPLACE as-is, back/forward/go
|
|
56
|
+
* mapped to POP (old-router `BlockerFunction` parity). Returns the
|
|
57
|
+
* unblock function.
|
|
58
|
+
*/
|
|
59
|
+
block(onLeave: (args: BlockerArgs) => boolean): () => void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isModalRouteId(router: ReturnType<typeof useRouter>, routeId: string): boolean {
|
|
63
|
+
return looseRouteOf(router, routeId)?.options.staticData?.modal !== undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface BlockerArgs {
|
|
67
|
+
/** The location the navigation starts from (TanStack history location). */
|
|
68
|
+
currentLocation: { href: string; pathname: string };
|
|
69
|
+
/** The location the navigation is heading to. */
|
|
70
|
+
nextLocation: { href: string; pathname: string };
|
|
71
|
+
/** PUSH/REPLACE as-is; back, forward and go are one POP (old-router parity). */
|
|
72
|
+
action: "PUSH" | "REPLACE" | "POP";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A typed page object — a panel definition (`defineView().panel(...)`) or
|
|
77
|
+
* a panel handle (`app.panels[...]`). The params type flows from the
|
|
78
|
+
* definition's phantom `types`, so `navigator.push(postPanel, { id })`
|
|
79
|
+
* type-checks the params (old-router `Page`/`PageWithParams` parity,
|
|
80
|
+
* without the `hasParams` discriminator quirk).
|
|
81
|
+
*/
|
|
82
|
+
export interface PanelPage<TParams = Record<string, string>> {
|
|
83
|
+
path: string;
|
|
84
|
+
types?: { params: TParams };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function useNavigator(): VkNavigator {
|
|
88
|
+
const router = useRouter();
|
|
89
|
+
const navigate = useNavigate();
|
|
90
|
+
const location = useRouterState({ select: state => state.location });
|
|
91
|
+
const modalOpen = useRouterState({
|
|
92
|
+
select: state => state.matches.some(match => isModalRouteId(router, match.routeId)),
|
|
93
|
+
});
|
|
94
|
+
const showPopout = useShowPopout();
|
|
95
|
+
const hidePopout = useHidePopout();
|
|
96
|
+
|
|
97
|
+
/** `"index"` and an optional leading slash normalize to the full path. */
|
|
98
|
+
const pageTo = (page: PanelPage): string =>
|
|
99
|
+
page.path === "index" ? "/" : `/${page.path.replace(/^\//, "")}`;
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
push: ((
|
|
103
|
+
pageOrOptions: NavigateOptions | PanelPage,
|
|
104
|
+
params?: Record<string, string>,
|
|
105
|
+
rest?: Omit<NavigateOptions, "to" | "params">,
|
|
106
|
+
) =>
|
|
107
|
+
params === undefined
|
|
108
|
+
? navigate({
|
|
109
|
+
...(pageOrOptions as NavigateOptions),
|
|
110
|
+
replace: false,
|
|
111
|
+
})
|
|
112
|
+
: navigate({
|
|
113
|
+
to: pageTo(pageOrOptions as PanelPage),
|
|
114
|
+
params,
|
|
115
|
+
replace: false,
|
|
116
|
+
...rest,
|
|
117
|
+
})) as VkNavigator["push"],
|
|
118
|
+
replace: ((
|
|
119
|
+
pageOrOptions: NavigateOptions | PanelPage,
|
|
120
|
+
params?: Record<string, string>,
|
|
121
|
+
rest?: Omit<NavigateOptions, "to" | "params">,
|
|
122
|
+
) =>
|
|
123
|
+
params === undefined
|
|
124
|
+
? navigate({
|
|
125
|
+
...(pageOrOptions as NavigateOptions),
|
|
126
|
+
replace: true,
|
|
127
|
+
})
|
|
128
|
+
: navigate({
|
|
129
|
+
to: pageTo(pageOrOptions as PanelPage),
|
|
130
|
+
params,
|
|
131
|
+
replace: true,
|
|
132
|
+
...rest,
|
|
133
|
+
})) as VkNavigator["replace"],
|
|
134
|
+
back: () => {
|
|
135
|
+
router.history.back();
|
|
136
|
+
},
|
|
137
|
+
backToFirst: () => {
|
|
138
|
+
const index =
|
|
139
|
+
(location.state as { __TSR_index?: number } | undefined)?.__TSR_index ?? 0;
|
|
140
|
+
if (index > 0) {
|
|
141
|
+
router.history.go(-index);
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
showModal: ({ props, ...options }) => {
|
|
145
|
+
// nested modal: the target route is a CHILD of the currently open
|
|
146
|
+
// modal — PUSH (back unwinds the stack); sibling modals keep the
|
|
147
|
+
// old-router replace parity
|
|
148
|
+
let nested = false;
|
|
149
|
+
if (modalOpen && typeof options.to === "string") {
|
|
150
|
+
for (let i = router.state.matches.length - 1; i >= 0; i--) {
|
|
151
|
+
const matchRouteId = router.state.matches[i]?.routeId;
|
|
152
|
+
if (matchRouteId === undefined) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const route = looseRouteOf(router, matchRouteId);
|
|
156
|
+
if (route?.options.staticData?.modal === undefined) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
nested =
|
|
160
|
+
route.fullPath !== undefined && options.to.startsWith(`${route.fullPath}/`);
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// TanStack types history state as its own updater shape — arbitrary
|
|
165
|
+
// serializable keys go through a cast (same as the popout state)
|
|
166
|
+
const withProps =
|
|
167
|
+
props !== undefined
|
|
168
|
+
? {
|
|
169
|
+
state: {
|
|
170
|
+
...(options.state as Record<string, unknown> | undefined),
|
|
171
|
+
[MODAL_STATE_KEY]: { props },
|
|
172
|
+
} as never,
|
|
173
|
+
}
|
|
174
|
+
: {};
|
|
175
|
+
return navigate({
|
|
176
|
+
...options,
|
|
177
|
+
// a modal open never changes the panel behind it — carry the
|
|
178
|
+
// panel's search (?tab=…) onto the modal entry, or the covered
|
|
179
|
+
// panel would snap to its default search for the whole time the
|
|
180
|
+
// modal is open (and snap back on close). An explicit `search`
|
|
181
|
+
// from the caller always wins.
|
|
182
|
+
search: options.search ?? true,
|
|
183
|
+
replace: nested ? false : modalOpen,
|
|
184
|
+
...withProps,
|
|
185
|
+
});
|
|
186
|
+
},
|
|
187
|
+
hideModal: () => {
|
|
188
|
+
if (router.history.canGoBack()) {
|
|
189
|
+
router.history.back();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
// deep-linked modal on the first entry: replace down to the panel
|
|
193
|
+
const parentPath = location.pathname.replace(/\/[^/]+$/, "") || "/";
|
|
194
|
+
void router.navigate({ href: parentPath, replace: true });
|
|
195
|
+
},
|
|
196
|
+
showPopout: (id, props) => {
|
|
197
|
+
showPopout(id, props);
|
|
198
|
+
},
|
|
199
|
+
hidePopout: () => {
|
|
200
|
+
hidePopout();
|
|
201
|
+
},
|
|
202
|
+
showGlobalModal: (id, { state, props, ...rest } = {}) => {
|
|
203
|
+
const { pathname, searchStr, hash } = router.state.location;
|
|
204
|
+
const params = new URLSearchParams(searchStr.replace(/^\?/, ""));
|
|
205
|
+
const already = params.get("modal") === id;
|
|
206
|
+
params.set("modal", id);
|
|
207
|
+
const href = `${pathname}?${params.toString()}${hash}`;
|
|
208
|
+
// the mask keeps the visible URL at the panel path while the modal
|
|
209
|
+
// is open (the modal param rides in history.state)
|
|
210
|
+
const maskParams = new URLSearchParams(params);
|
|
211
|
+
maskParams.delete("modal");
|
|
212
|
+
const maskQuery = maskParams.toString();
|
|
213
|
+
const maskHref = `${pathname}${maskQuery !== "" ? `?${maskQuery}` : ""}${hash}`;
|
|
214
|
+
const nextState =
|
|
215
|
+
state !== undefined || props !== undefined
|
|
216
|
+
? {
|
|
217
|
+
state: {
|
|
218
|
+
...state,
|
|
219
|
+
...(props !== undefined ? { [MODAL_STATE_KEY]: { props } } : {}),
|
|
220
|
+
} as never,
|
|
221
|
+
}
|
|
222
|
+
: {};
|
|
223
|
+
return navigate({
|
|
224
|
+
href,
|
|
225
|
+
mask: { to: maskHref },
|
|
226
|
+
replace: already || modalOpen,
|
|
227
|
+
...rest,
|
|
228
|
+
...nextState,
|
|
229
|
+
});
|
|
230
|
+
},
|
|
231
|
+
block: onLeave => {
|
|
232
|
+
const unblock = router.history.block({
|
|
233
|
+
// args infer as TanStack's BlockerFnArgs (HistoryLocation × 2 +
|
|
234
|
+
// action) — assignable to our structural BlockerArgs
|
|
235
|
+
blockerFn: args =>
|
|
236
|
+
onLeave({
|
|
237
|
+
currentLocation: args.currentLocation,
|
|
238
|
+
nextLocation: args.nextLocation,
|
|
239
|
+
// TanStack distinguishes BACK/FORWARD/GO; the old router's
|
|
240
|
+
// BlockerFunction saw one POP — keep the parity
|
|
241
|
+
action:
|
|
242
|
+
args.action === "PUSH" || args.action === "REPLACE"
|
|
243
|
+
? args.action
|
|
244
|
+
: "POP",
|
|
245
|
+
}),
|
|
246
|
+
});
|
|
247
|
+
return unblock;
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
}
|
package/src/preset.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export {
|
|
2
|
+
createApp,
|
|
3
|
+
panel,
|
|
4
|
+
type AppHandle,
|
|
5
|
+
type AppOptions,
|
|
6
|
+
type PanelHandle,
|
|
7
|
+
type PanelOptions,
|
|
8
|
+
type PanelHandlesOf,
|
|
9
|
+
type TypedAppHandle,
|
|
10
|
+
type ViewOptions,
|
|
11
|
+
} from "./preset/builders.tsx";
|
|
12
|
+
export { AppRootShell, useActiveStory, useInflate } from "./preset/app.tsx";
|
|
13
|
+
export { deriveInflateChain } from "./preset/inflate.ts";
|
|
14
|
+
export { useNavigator, type VkNavigator } from "./preset/navigator.ts";
|
|
15
|
+
export { definePanel, panelRouteId, type PanelDefinition } from "./preset/define-panel.ts";
|
|
16
|
+
export {
|
|
17
|
+
type ModalComponent,
|
|
18
|
+
type ModalDeclaration,
|
|
19
|
+
type ModalEntryComponent,
|
|
20
|
+
} from "./preset/builders.tsx";
|
|
21
|
+
export { defineView, type ViewDefinition } from "./preset/define-view.ts";
|
|
22
|
+
|
|
23
|
+
// the batteries a mini app almost always needs, one import away
|
|
24
|
+
export {
|
|
25
|
+
createVkHistory,
|
|
26
|
+
createBridgeSync,
|
|
27
|
+
createSwipeBackManager,
|
|
28
|
+
normalizeBridgeEvent,
|
|
29
|
+
type VkBridgeLike,
|
|
30
|
+
} from "./index.ts";
|
|
31
|
+
export {
|
|
32
|
+
Modals,
|
|
33
|
+
ModalPageSurface,
|
|
34
|
+
ModalCardSurface,
|
|
35
|
+
Popouts,
|
|
36
|
+
VkView,
|
|
37
|
+
NotFound,
|
|
38
|
+
useShowPopout,
|
|
39
|
+
useHidePopout,
|
|
40
|
+
usePopoutState,
|
|
41
|
+
type ModalEntry,
|
|
42
|
+
} from "./vkui.ts";
|
|
43
|
+
export {
|
|
44
|
+
usePanelParams,
|
|
45
|
+
usePanelSearch,
|
|
46
|
+
usePanelLoaderData,
|
|
47
|
+
useModalProps,
|
|
48
|
+
usePanelsHistory,
|
|
49
|
+
useEnableSwipeBack,
|
|
50
|
+
type PanelRef,
|
|
51
|
+
} from "./react.ts";
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { useMatch, useRouter, useRouterState } from "@tanstack/react-router";
|
|
2
|
+
import { useEffect, useRef } from "react";
|
|
3
|
+
import { looseRouteOf } from "../core/loose-route.ts";
|
|
4
|
+
import {
|
|
5
|
+
applyLocation,
|
|
6
|
+
initialPanelsStack,
|
|
7
|
+
type PanelsStackState,
|
|
8
|
+
panelsHistoryOf,
|
|
9
|
+
} from "../core/panels-stack.ts";
|
|
10
|
+
import { readModalState } from "../core/state-keys.ts";
|
|
11
|
+
import { setSwipeBackOverride } from "../core/swipe-back.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The departing-panel freeze (spike 1): raw TanStack data hooks throw
|
|
15
|
+
* `Invariant failed: Could not find an active match` when the panel's
|
|
16
|
+
* route stops matching while VKUI still keeps the panel mounted during the
|
|
17
|
+
* back animation — the error flies past the router's error boundary and
|
|
18
|
+
* remounts the whole tree. These hooks never throw and retain the last
|
|
19
|
+
* resolved value while the panel is mounted but unmatched.
|
|
20
|
+
*
|
|
21
|
+
* Pass the TYPED panel handle (`app.panels["feed/post/$id"]`) — params /
|
|
22
|
+
* search / loaderData are derived from the panel's declared options
|
|
23
|
+
* (validateSearch return, loader return, $param path segments). A plain
|
|
24
|
+
* route-id string also works, with loose types.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Structural twin of the preset's `PanelHandle` — the phantom `types`
|
|
29
|
+
* member carries the panel's data types without coupling this layer to
|
|
30
|
+
* the preset.
|
|
31
|
+
*/
|
|
32
|
+
export interface PanelRef<
|
|
33
|
+
TParams = Record<string, string>,
|
|
34
|
+
TSearch = Record<string, unknown>,
|
|
35
|
+
TLoaderData = unknown,
|
|
36
|
+
> {
|
|
37
|
+
readonly routeId: string;
|
|
38
|
+
readonly types?: { params: TParams; search: TSearch; loaderData: TLoaderData };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function useLastWhileUndefined<T>(value: T | undefined): T | undefined {
|
|
42
|
+
const ref = useRef(value);
|
|
43
|
+
if (value !== undefined) {
|
|
44
|
+
ref.current = value;
|
|
45
|
+
}
|
|
46
|
+
return value === undefined ? ref.current : value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Shared body of the panel data hooks — they differ only in the match field. */
|
|
50
|
+
function usePanelMatchField(
|
|
51
|
+
refOrFrom: PanelRef | string,
|
|
52
|
+
field: "params" | "search" | "loaderData",
|
|
53
|
+
): unknown {
|
|
54
|
+
const routeId = typeof refOrFrom === "string" ? refOrFrom : refOrFrom.routeId;
|
|
55
|
+
const match = useMatch({ from: routeId, shouldThrow: false } as never);
|
|
56
|
+
return useLastWhileUndefined(
|
|
57
|
+
(match as unknown as Record<string, unknown> | undefined)?.[field],
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function usePanelParams<P extends Record<string, string>>(
|
|
62
|
+
ref: PanelRef<P | undefined, any, any>,
|
|
63
|
+
): P;
|
|
64
|
+
export function usePanelParams(from: string): Record<string, string>;
|
|
65
|
+
export function usePanelParams(refOrFrom: PanelRef | string): unknown {
|
|
66
|
+
return usePanelMatchField(refOrFrom, "params") ?? {};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function usePanelSearch<S>(ref: PanelRef<any, S | undefined, any>): S | undefined;
|
|
70
|
+
export function usePanelSearch(from: string): Record<string, unknown>;
|
|
71
|
+
export function usePanelSearch(refOrFrom: PanelRef | string): unknown {
|
|
72
|
+
return usePanelMatchField(refOrFrom, "search");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function usePanelLoaderData<D>(ref: PanelRef<any, any, D | undefined>): D | undefined;
|
|
76
|
+
export function usePanelLoaderData(from: string): unknown;
|
|
77
|
+
export function usePanelLoaderData(refOrFrom: PanelRef | string): unknown {
|
|
78
|
+
return usePanelMatchField(refOrFrom, "loaderData");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Runtime props of the open modal — `showModal({ to, params, props })` /
|
|
83
|
+
* `showGlobalModal(id, { props })` stash them in `history.state` (so they
|
|
84
|
+
* survive reload and back/forward) and the modal entry component reads them
|
|
85
|
+
* here. Serializable values only (structured clone); same freeze semantics
|
|
86
|
+
* as the panel hooks — the departing modal keeps its last props while it
|
|
87
|
+
* animates out.
|
|
88
|
+
*/
|
|
89
|
+
export function useModalProps<T = unknown>(): T | undefined {
|
|
90
|
+
const modal = useRouterState({ select: state => readModalState(state.location.state) });
|
|
91
|
+
return useLastWhileUndefined(modal?.props) as T | undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Mirror of the history stack in panel ids, driven by `__TSR_index`.
|
|
96
|
+
* Applies only when the router is idle — between the location commit and
|
|
97
|
+
* the match resolution there is an intermediate render with a stale
|
|
98
|
+
* activePanel (spike 1: `[home, home]` flash, self-healed by the
|
|
99
|
+
* replace-on-same-index rule).
|
|
100
|
+
*/
|
|
101
|
+
export function usePanelsStack(activePanel: string): PanelsStackState {
|
|
102
|
+
const snapshot = useRouterState({
|
|
103
|
+
select: state => ({
|
|
104
|
+
status: state.status,
|
|
105
|
+
index:
|
|
106
|
+
(state.location.state as { __TSR_index?: number } | undefined)?.__TSR_index ?? null,
|
|
107
|
+
}),
|
|
108
|
+
});
|
|
109
|
+
const ref = useRef<PanelsStackState>(initialPanelsStack());
|
|
110
|
+
|
|
111
|
+
if (snapshot.status === "idle") {
|
|
112
|
+
ref.current = applyLocation(ref.current, snapshot.index, activePanel);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return ref.current;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** VKUI `View.history` prop: panels in open order. */
|
|
119
|
+
export function usePanelsHistory(activePanel: string): string[] {
|
|
120
|
+
return panelsHistoryOf(usePanelsStack(activePanel));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The deepest matched panel's route id — what "the active panel" means
|
|
125
|
+
* when every panel is a permanently mounted static child.
|
|
126
|
+
*/
|
|
127
|
+
function useActivePanelRouteId(): string | null {
|
|
128
|
+
const router = useRouter();
|
|
129
|
+
return useRouterState({
|
|
130
|
+
select: (state): string | null => {
|
|
131
|
+
for (let i = state.matches.length - 1; i >= 0; i--) {
|
|
132
|
+
const routeId = state.matches[i]?.routeId;
|
|
133
|
+
if (routeId === undefined) {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (looseRouteOf(router, routeId)?.options.staticData?.panel !== undefined) {
|
|
137
|
+
return routeId;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The old router's `useEnableSwipeBack`: override the automatic
|
|
147
|
+
* swipe-back decision while it applies. Panels are permanently mounted
|
|
148
|
+
* static children here, so pass the panel ref — the override applies only
|
|
149
|
+
* while that panel is the deepest matched one and is released when the
|
|
150
|
+
* user navigates away. Without a ref the override is app-global.
|
|
151
|
+
*
|
|
152
|
+
* `false` turns the native gesture off (drawing pads, drag surfaces);
|
|
153
|
+
* `true` forces it on even on the first entry (the system back then
|
|
154
|
+
* closes the app — you asked for it). `createSwipeBackManager({
|
|
155
|
+
* enabled: false })` is the hard opt-out that wins over this hook.
|
|
156
|
+
*/
|
|
157
|
+
export function useEnableSwipeBack(enabled: boolean, ref?: PanelRef | string): void {
|
|
158
|
+
const activeRouteId = useActivePanelRouteId();
|
|
159
|
+
const routeId = typeof ref === "string" ? ref : ref?.routeId;
|
|
160
|
+
const applies = ref === undefined ? true : routeId !== undefined && routeId === activeRouteId;
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
if (!applies) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
setSwipeBackOverride(enabled);
|
|
166
|
+
return () => setSwipeBackOverride(null);
|
|
167
|
+
}, [applies, enabled]);
|
|
168
|
+
}
|